34 Revīzijas a663ef0136 ... d6f2d7ccbf

Autors SHA1 Ziņojums Datums
  Dalibor Votruba d6f2d7ccbf Release v1.5.0 — per-order-item print status feature 2 mēneši atpakaļ
  Dalibor Votruba 746676ccb0 Add implementation analysis for per-order-item print status (v1.5.0) 2 mēneši atpakaļ
  Dalibor Votruba 54a62e7c7d Release v1.4.5 — resolve in-scope findings from revise-1.4.4 2 mēneši atpakaļ
  Dalibor Votruba ae7e82fc06 Release v1.4.4 — resolve every in-scope finding from revise-1.4.3 2 mēneši atpakaļ
  Dalibor Votruba e8a0088455 Refresh cs_CZ .po — sync translations with v1.4.3 source 2 mēneši atpakaļ
  Dalibor Votruba b5fd5bb14f Release v1.4.3 — resolve every in-scope finding from revise-1.4.2 2 mēneši atpakaļ
  Dalibor Votruba 1d6e0a0a06 Release v1.4.2 — resolve every in-scope finding from revise-1.4.1 2 mēneši atpakaļ
  Dalibor Votruba f8b7de9317 Release v1.4.1 — resolve every in-scope finding from revise-1.4.0 2 mēneši atpakaļ
  Dalibor Votruba 395b9efb3d Release v1.4.0 — fix every finding from revise-1.3.2 + WC 10.7.0/WP 6.9.4 2 mēneši atpakaļ
  Dalibor Votruba 1f0ac1aec7 Add analytical revision of v1.3.2 to docs/revise-1.3.2.md 2 mēneši atpakaļ
  Dalibor Votruba c1771942f0 Reorganize docs into docs/ folder and add fresh README.md 2 mēneši atpakaļ
  Dalibor Votruba 1efef13658 Merge branch '1.0.0.studiou-wc-free-photo-product' 2 mēneši atpakaļ
  Dalibor Votruba c9cbb9d764 Harden cart total i18n override against bundle clobber — v1.5.13 -> v1.5.14 2 mēneši atpakaļ
  Dalibor Votruba 3333cbf29a Override WC block-cart "Estimated total" via wp.i18n — v1.5.12 -> v1.5.13 2 mēneši atpakaļ
  Dalibor Votruba 0cf4c5766d regenerate .mo file 2 mēneši atpakaļ
  Dalibor Votruba 224a12b555 Variant price table above dropzone + rename cart total — v1.5.11 -> v1.5.12 2 mēneši atpakaļ
  Dalibor Votruba 7d303370f9 Fix mobile dropzone infinite-recursion on tap-to-upload — v1.5.10 -> v1.5.11 2 mēneši atpakaļ
  Dalibor Votruba 603c2c6b1c Decode WC currency symbol + priceFormat before handing to JS — v1.5.9 -> v1.5.10 2 mēneši atpakaļ
  Dalibor Votruba 5e6b80b47e Fix HTML entities leaking into variant combobox labels — v1.5.8 -> v1.5.9 2 mēneši atpakaļ
  Dalibor Votruba 06faf7bf00 Add photo lightbox + fix block-cart thumbnail mismatch — v1.5.3 -> v1.5.8 2 mēneši atpakaļ
  Dalibor Votruba f4ce70ef52 Remove legacy WC-session + per-upload cookie transports — v1.5.2 -> v1.5.3 2 mēneši atpakaļ
  Dalibor Votruba e2596fd49c regenerate .mo file 2 mēneši atpakaļ
  Dalibor Votruba 280bff93a3 Fix: pvtfw table + Available Options button not actually suppressed — v1.5.1 -> v1.5.2 2 mēneši atpakaļ
  Dalibor Votruba 65e9fbbbb6 Fix: escape-stripped price in card variant <select> — v1.5.0 -> v1.5.1 2 mēneši atpakaļ
  Dalibor Votruba 9348551f0a add implementation instructions for multiupload functionality 2 mēneši atpakaļ
  Dalibor Votruba e5b3fd4a6a 1.5.0 phase 5: docs + i18n updates 2 mēneši atpakaļ
  Dalibor Votruba 5563c2e2bb 1.5.0 phase 2+3: upload cards stack + per-card add-to-cart 2 mēneši atpakaļ
  Dalibor Votruba 78a24b3032 1.5.0 phase 1: batch-token transport for multi-upload 2 mēneši atpakaļ
  Dalibor Votruba 902f8bb337 claude.settings.local 2 mēneši atpakaļ
  Dalibor Votruba 3ed3dd23a7 regenerate .mo translation 2 mēneši atpakaļ
  Dalibor Votruba ee4ed1cb52 Admin summary: order-number filter, sortable columns, CSV export — v1.3.5 -> v1.3.6 2 mēneši atpakaļ
  Dalibor Votruba 3aa5b6967a Fix add-to-cart blocked after upload with PVT 1.9.3 + WC 10.7.0 — v1.3.4 -> v1.3.5 2 mēneši atpakaļ
  Dalibor Votruba fe647c82b7 Add per-variation quantity discount tiers — v1.2.3 -> v1.3.4 3 mēneši atpakaļ
  Dalibor Votruba 7fb2ad794f Fix admin download serving WP-scaled image instead of raw upload — v1.2.2 -> v1.2.3 3 mēneši atpakaļ
47 mainītis faili ar 8619 papildinājumiem un 1117 dzēšanām
  1. 16 1
      studiou-wc-free-photo-product/.claude/settings.local.json
  2. 52 30
      studiou-wc-free-photo-product/CLAUDE.md
  3. 61 0
      studiou-wc-free-photo-product/assets/css/admin.css
  4. 729 0
      studiou-wc-free-photo-product/assets/css/frontend.css
  5. 80 0
      studiou-wc-free-photo-product/assets/js/admin.js
  6. 738 204
      studiou-wc-free-photo-product/assets/js/frontend.js
  7. 157 16
      studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-admin.php
  8. 180 49
      studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-cart.php
  9. 60 2
      studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-db.php
  10. 342 0
      studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-pricing.php
  11. 24 0
      studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-product.php
  12. 222 70
      studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-single-product.php
  13. 74 2
      studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-upload.php
  14. BIN
      studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.mo
  15. 138 1
      studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.po
  16. 187 1
      studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product.pot
  17. 351 0
      studiou-wc-free-photo-product/multiupload-photo-instruction.md
  18. 109 21
      studiou-wc-free-photo-product/readme.md
  19. 125 3
      studiou-wc-free-photo-product/studiou-wc-free-photo-product.php
  20. 60 11
      studiou-wc-free-photo-product/views/admin-summary.php
  21. 107 0
      studiou-wc-free-photo-product/views/single-product-order-overview.php
  22. 51 0
      studiou-wc-free-photo-product/views/single-product-price-table.php
  23. 29 27
      studiou-wc-free-photo-product/views/single-product-upload.php
  24. 153 16
      studiou-wc-ord-print-statuses/CLAUDE.md
  25. 406 0
      studiou-wc-ord-print-statuses/README.md
  26. 397 0
      studiou-wc-ord-print-statuses/docs/feature-order-item-status-analysis.md
  27. 0 0
      studiou-wc-ord-print-statuses/docs/instructions.txt
  28. 0 0
      studiou-wc-ord-print-statuses/docs/readme.md
  29. 291 0
      studiou-wc-ord-print-statuses/docs/revise-1.3.2.md
  30. 344 0
      studiou-wc-ord-print-statuses/docs/revise-1.4.0.md
  31. 356 0
      studiou-wc-ord-print-statuses/docs/revise-1.4.1.md
  32. 328 0
      studiou-wc-ord-print-statuses/docs/revise-1.4.2.md
  33. 267 0
      studiou-wc-ord-print-statuses/docs/revise-1.4.3.md
  34. 260 0
      studiou-wc-ord-print-statuses/docs/revise-1.4.4.md
  35. 111 0
      studiou-wc-ord-print-statuses/docs/translations.txt
  36. 98 41
      studiou-wc-ord-print-statuses/includes/class-bulk-actions-manager.php
  37. 102 51
      studiou-wc-ord-print-statuses/includes/class-custom-columns-manager.php
  38. 467 163
      studiou-wc-ord-print-statuses/includes/class-db-manager.php
  39. 169 53
      studiou-wc-ord-print-statuses/includes/class-import-manager.php
  40. 133 66
      studiou-wc-ord-print-statuses/includes/class-order-fields-manager.php
  41. 349 0
      studiou-wc-ord-print-statuses/includes/class-order-item-status-manager.php
  42. 26 50
      studiou-wc-ord-print-statuses/includes/class-order-search-manager.php
  43. 115 79
      studiou-wc-ord-print-statuses/includes/class-order-status-manager.php
  44. 78 28
      studiou-wc-ord-print-statuses/includes/utils-log.php
  45. 250 82
      studiou-wc-ord-print-statuses/languages/studiou-wc-ord-print-statuses-cs_CZ.po
  46. 27 25
      studiou-wc-ord-print-statuses/studiou-wc-ord-print-statuses.php
  47. 0 25
      studiou-wc-ord-print-statuses/translations.txt

+ 16 - 1
studiou-wc-free-photo-product/.claude/settings.local.json

@@ -3,7 +3,22 @@
     "allow": [
       "Bash(command -v php)",
       "Bash(php -v)",
-      "WebFetch(domain:www.studiou.cz)"
+      "WebFetch(domain:www.studiou.cz)",
+      "Bash(git:*)",
+      "Bash(php generate-mo.php)",
+      "Bash(curl -sL -A \"Mozilla/5.0\" \"https://www.studiou.cz/produkt/filo\" -o /tmp/filo.html)",
+      "Read(//tmp/**)",
+      "Bash(curl -sL -A \"Mozilla/5.0\" \"https://www.studiou.cz/produkt/filo\" -o \"D:/temp_filo.html\")",
+      "Read(//d//**)",
+      "Bash(curl -s \"https://www.studiou.cz/produkt/filo/\" -A \"Mozilla/5.0\")",
+      "Bash(curl -s -o /tmp/filo.html \"https://www.studiou.cz/produkt/filo/\" -A \"Mozilla/5.0\")",
+      "Bash(curl -sI \"https://www.studiou.cz/wp-content/plugins/studiou-wc-free-photo-product/assets/js/frontend.js\" -A \"Mozilla/5.0\")",
+      "Bash(curl -s \"https://www.studiou.cz/wp-content/plugins/studiou-wc-free-photo-product/assets/js/frontend.js\" -A \"Mozilla/5.0\" -o /tmp/filo.js)",
+      "Bash(sed -n '30,45p' /tmp/filo.js)",
+      "Bash(sed -n '590,650p' /tmp/filo.js)",
+      "Bash(curl -s \"https://www.studiou.cz/wp-content/plugins/studiou-wc-free-photo-product/assets/css/frontend.css\" -A \"Mozilla/5.0\" -o /tmp/filo.css)",
+      "Bash(curl -s \"https://www.studiou.cz/wp-content/themes/grandportfolio/js/custom.js\" -A \"Mozilla/5.0\" -o /tmp/theme-custom.js)",
+      "Bash(curl -s \"https://www.studiou.cz/wp-content/themes/grandportfolio/js/custom_plugins.js\" -A \"Mozilla/5.0\" -o /tmp/theme-custom-plugins.js)"
     ]
   }
 }

+ 52 - 30
studiou-wc-free-photo-product/CLAUDE.md

@@ -2,7 +2,7 @@
 
 ## Project Overview
 
-WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.2.1.
+WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.5.14.
 Allows publishing special variable products where customers upload custom raw images to be printed for a price.
 
 ## Initial Requirements
@@ -12,14 +12,16 @@ Allows publishing special variable products where customers upload custom raw im
 3. Uploaded raw files stored in WP media library with a specific media category per product. Chunked upload to bypass PHP upload size limits. Media files linked with product.
 4. Admin summary page: list of uploaded files, download as ZIP (bulk), order number, delete, change state (downloaded, ready, solved).
 5. HPOS support and localization (English, Czech).
+6. **1.5.0 change:** multi-upload — customers upload *many* photos in one visit, product page shows a vertical stack of cards (one per upload) instead of pvtfw's variant table. Each card has its own variant selector + qty + live price + Add-to-Cart button. Order-overview panel below lists this product's cart lines with per-line remove and subtotal. First uploaded file becomes the product hero image.
 
 ## Tech Stack
 
 - PHP 8.2+, WordPress 6.9.4+, WooCommerce 10.0+
+- Hard plugin dependency: **Product Variant Table for WooCommerce** (`pvtfw`, folder slug `product-variant-table-for-woocommerce`) — required for non-FPP variable products across the shop. **Suppressed on FPP products** since 1.5.0 — our card stack is the sole Add-to-Cart surface there. Declared via the `Requires Plugins:` header and enforced at runtime by `Studiou_WC_Free_Photo_Product::is_pvtfw_active()`.
 - jQuery for frontend/admin JS
-- WordPress AJAX (admin-ajax.php) for chunked upload
-- WC session storage for passing file data between upload and add-to-cart (theme-agnostic)
-- Custom DB table via dbDelta for file tracking
+- WordPress AJAX (admin-ajax.php) for chunked upload + per-card cart ops
+- First-party cookie `studiou_fpp_batch` as batch identifier — survives the classic-session <-> Store-API Cart-Token split
+- Custom DB table via dbDelta for file tracking (`wp_studiou_fpp_files`, with `batch_token` column indexed)
 - Custom taxonomy `studiou_media_category` on `attachment` post type
 
 ## Architecture
@@ -30,33 +32,53 @@ Allows publishing special variable products where customers upload custom raw im
 - JS namespace: `studiouWcfppFront` (frontend), `studiouWcfpp` (admin)
 - Nonces: `studiou-wcfpp-front-nonce` (frontend), `studiou-wcfpp-nonce` (admin)
 - Text domain: `studiou-wc-free-photo-product`
+- Schema upgrade is automatic: `maybe_upgrade_db()` on plugin load compares `studiou_wcfpp_db_version` option against `STUDIOU_WCFPP_VERSION` and runs `dbDelta` on mismatch.
 
 ## Key Classes
 
-- `Studiou_WC_FPP_DB` - Custom table CRUD, media category taxonomy
-- `Studiou_WC_FPP_Product` - WC product data tab, product meta
-- `Studiou_WC_FPP_Upload` - Chunked upload AJAX, file assembly, attachment creation
-- `Studiou_WC_FPP_Single_Product` - Upload area on product page (`woocommerce_single_product_summary` priority 35), WC session storage, server-side validation (`woocommerce_add_to_cart_validation` priority 1) + safety net (`woocommerce_add_to_cart` removes items without photo)
-- `Studiou_WC_FPP_Shortcode` - `[studiou_free_photo_product id="X"]` renders product with WC native variation form
-- `Studiou_WC_FPP_Cart` - Reads file data from WC session via `woocommerce_add_cart_item_data`, replaces cart/order item thumbnails (classic cart via `woocommerce_cart_item_thumbnail`, block cart via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`), order item meta, order linking
-- `Studiou_WC_FPP_Admin` - Summary page with filters/pagination, status management, ZIP download, file download with auto-status-change, bulk actions with confirmation dialogs
-
-## Frontend Flow
-
-1. Upload area rendered below variation table via `woocommerce_single_product_summary` (priority 35)
-2. On page load, existing session state restored (preview, gallery image)
-3. Customer uploads photo via chunked AJAX
-4. On upload complete, JS stores file ref in WC session
-5. Product gallery image replaced with uploaded photo preview; restored on remove
-6. Customer selects variant and clicks any Add to Cart button (buttons look normal — no client-side disabling)
-7. `woocommerce_add_to_cart_validation` (priority 1) checks WC session — blocks with error notice if no photo
-8. `woocommerce_add_to_cart` safety net — removes item if it was added without photo data
-9. `woocommerce_add_cart_item_data` reads file data from WC session (session NOT cleared — same photo reusable for multiple variants)
-10. Cart thumbnails: classic cart via `woocommerce_cart_item_thumbnail`, block cart via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`
-11. Order admin: thumbnail via `woocommerce_admin_order_item_thumbnail`, download link via `woocommerce_after_order_itemmeta` (auto-marks status as "downloaded")
-12. On checkout, file record linked to order via `woocommerce_checkout_order_processed` (classic), `woocommerce_store_api_checkout_order_processed` (block), `woocommerce_thankyou` (fallback)
-13. Session only cleared when user clicks Remove
-14. Admin photo files view: download marks "ready" -> "downloaded", bulk actions with confirmations, pagination preserves filters
+- `Studiou_WC_FPP_DB` - Custom table CRUD, media category taxonomy. Batch-aware queries: `get_batch_files($token, $product_id)`, `count_batch_files($token, $product_id)`.
+- `Studiou_WC_FPP_Product` - WC product data tab, product meta (including `_studiou_fpp_max_uploads` — per-product cap on simultaneous uploads, 0 = unlimited).
+- `Studiou_WC_FPP_Upload` - Chunked upload AJAX, file assembly, attachment creation. Mints/reuses a 32-char `studiou_fpp_batch` cookie on first chunk-upload completion and persists it in the new file record's `batch_token` column. Enforces the per-product max-uploads cap before accepting chunk 0. The batch cookie is the **sole** transport since 1.5.3 — legacy WC-session keys and the short-lived `studiou_fpp_att_id`/`studiou_fpp_rec_id` cookie pair have been removed.
+- `Studiou_WC_FPP_Single_Product` - On `wp` priority 5, detaches pvtfw's variant table (`remove_action()` on the hook/priority read from pvtfw's own `pvtfw_variant_table_place` option) and pvtfw's available-options button on FPP products. pvtfw's singletons are reached via `PVTFW_PRINT_TABLE::instance()` / `PVTFW_AVAILABE_BTN::instance()` (the `$pvtfw_print_table` / `$pvtfw_available_btn` globals published by pvtfw end up as method-locals because pvtfw's `require_once` lives inside a method — the static singletons return the same real instances that pvtfw used to register the hooks). On `woocommerce_single_product_summary` priority 35 renders the upload dropzone + card stack + order-overview panel. Exposes `resolve_batch_files(Studiou_WC_FPP_DB $db, $product_id = 0)` (list, 1.5.0 path) and `resolve_uploaded_file()` (single, legacy shortcode path). Adds `studiou-fpp-product-page` body class. Server-side validation (`woocommerce_add_to_cart_validation` priority 1) accepts `$cart_item_data['studiou_fpp_file_record_id']` as the primary "photo present" signal, with the resolver as fallback.
+- `Studiou_WC_FPP_Shortcode` - `[studiou_free_photo_product id="X"]` renders product with WC native variation form (unchanged — still the single-file flow).
+- `Studiou_WC_FPP_Cart` - New AJAX endpoints `studiou_wcfpp_add_file_to_cart` (per-card Add-to-Cart, pre-stamps `file_record_id` on `$cart_item_data` and calls `WC()->cart->add_to_cart()`) and `studiou_wcfpp_remove_cart_line` (cart-only remove from overview panel). `add_cart_item_data()` prefers pre-stamped `file_record_id` from the new flow, falls back to the legacy visitor-level resolver. Replaces cart/order item thumbnails: classic cart via `woocommerce_cart_item_thumbnail`; block cart (Store API) via `woocommerce_get_cart_item_from_session` which clones the cart line's product and calls `set_image_id()` on the clone — this is cart-item-scoped so multiple lines sharing a `variation_id` still render their own uploaded photo (the previous `woocommerce_product_get_image_id` override fired per-product and mismatched thumbnails when several lines shared a variation, fixed in 1.5.4). `render_overview_html($product_id)` returns the panel HTML for in-place re-render after cart changes.
+- `Studiou_WC_FPP_Admin` - Summary page with filters (status, product, order number, file-name search) and pagination, sortable columns (Order / Status / Date — default Date DESC), status management, ZIP download, CSV export, file download with auto-status-change, bulk actions with confirmation dialogs.
+- `Studiou_WC_FPP_Pricing` - Per-variation quantity discount tiers. Admin editor on `woocommerce_variation_options_pricing`, save via `woocommerce_save_product_variation`, runtime discount via `woocommerce_before_calculate_totals` (priority 20) using `set_price()` on the cart item's product instance. Tier meta stored as a PHP array in `_studiou_fpp_qty_tiers` on each `product_variation` post. Feature is gated to FPP-enabled parent products. **Frontend (1.5.0+):** tier data is consumed directly by the upload-card JS — each card's `updateCardPrice()` reads the selected variant's tiers (embedded in `studiouFppCardData.variants`) and re-renders its price + `−X%` badge on variant/qty changes. `initVariantTableTiers()` (pvtfw) and `initQtyTiers()` (native WC form) were removed in 1.5.0.
+
+## Frontend Flow (1.5.0+)
+
+**FPP product page (`is_product()` + `is_fpp_product()`):**
+
+1. `maybe_suppress_pvtfw()` detaches pvtfw's `print_table` and `available_options_btn` hooks on `wp` priority 5.
+1a. (1.5.12+) `maybe_suppress_pvtfw()` also detaches `woocommerce_template_single_price` at priority 10 so WC's price-range span (e.g. "10,00 Kč – 40,00 Kč") is hidden on FPP products — the per-variant table below replaces it.
+1b. (1.5.12+) `render_variant_price_table()` on `woocommerce_single_product_summary` priority 34 renders a read-only server-side table (`views/single-product-price-table.php`) above the dropzone: one row per variant with unit price (via `wc_price()`) and tiered quantity-discount badges. Pure overview — no radio/qty/add-to-cart.
+1c. (1.5.12+) `override_wc_strings()` on `gettext_woocommerce` reroutes selected WC core strings (currently "Estimated total") through our own textdomain so the translation lives in our .po. This catches PHP-rendered strings only. (1.5.13+) The block cart is React-rendered and reads strings from `wp.i18n` instead, so an inline script on the `wp-i18n` handle calls `wp.i18n.setLocaleData({ 'Estimated total': […], 'Cart totals': […] }, 'woocommerce')` after wp-i18n loads — overriding the bundled "Odhadovaný součet" in the live DOM.
+2. `render_upload_area()` on `woocommerce_single_product_summary` priority 35 passes variants + max uploads + existing batch files + hero preview URL to JS via `wp_localize_script('studiou-wcfpp-frontend', 'studiouFppCardData', ...)`.
+3. Browser renders:
+   - **Dropzone** (multi-file `<input type="file" multiple>`) — accepts drag-drop + click-to-browse. Click-to-browse opens the native file picker via `$fileInput[0].click()` (NOT `$fileInput.trigger('click')`), because the `<input>` lives inside the dropzone element and a bubbling jQuery trigger re-entered the dropzone's own click handler, causing `Maximum call stack size exceeded` on mobile where the browser synthesizes an extra click when the picker closes (fixed in 1.5.11).
+   - **Cards stack** — one card per upload, all built client-side by `buildCard()` from `studiouFppCardData`.
+   - **Order overview panel** — rendered server-side initially from `WC()->cart` filtered to the current product; re-rendered in-place after each cart change.
+4. Customer drops photos → `enqueueFiles()` creates a "uploading" card per file, queue processes sequentially via chunked AJAX.
+5. Server-side upload handler:
+   - `resolve_or_mint_batch_token()` — reads `studiou_fpp_batch` cookie or mints a new 32-char token and sets the cookie.
+   - On chunk 0 of a new file, counts existing batch files for this product; rejects if `_studiou_fpp_max_uploads` is reached.
+   - On final chunk: `assemble_chunks()` creates the attachment and the DB row with `batch_token` stamped.
+6. On upload complete, JS swaps the card to "ready" state (thumb + variant dropdown + qty + price + Add to Cart + × Remove Upload).
+7. If this is the first card in the stack, JS also swaps the product-gallery image to the upload preview via `applyHeroImage()`. Remove-first-upload reverts to the next card's image or restores the original via `restoreHeroImage()`.
+8. Customer picks variant + qty on a card → `updateCardPrice()` computes (base_price × qty) with the applicable tier applied; shows `−X%` badge when a tier is active.
+9. Click "Add to cart" → `studiou_wcfpp_add_file_to_cart` AJAX with `file_record_id + variation_id + quantity`. Server:
+   - Verifies nonce + record ownership (`batch_token` match).
+   - Pre-stamps `$cart_item_data['studiou_fpp_file_record_id']`.
+   - Calls `WC()->cart->add_to_cart($parent_id, $qty, $variation_id, $variation_attrs, $cart_item_data)`.
+   - Returns `overview_html` (freshly-rendered panel) for in-place replacement.
+10. `woocommerce_add_cart_item_data` (our filter) reads the pre-stamped `file_record_id`, looks up the record, stamps `studiou_fpp_attachment_id` / `_file_name` / `_thumb_url` on the cart item, and updates the record's `variation_id`. Each (upload × variant × qty) Add-to-Cart creates a distinct cart line (WC's `generate_cart_id()` hashes `$cart_item_data`, so different `file_record_id`s never collapse).
+11. `woocommerce_before_calculate_totals` priority 20 (`Pricing::apply_cart_discount`) re-applies tier discounts on every recalc — authoritative server-side pricing.
+12. × on an upload card → `studiou_wcfpp_remove_upload` (deletes the DB row + attachment, clears legacy cookies). × on an overview line → `studiou_wcfpp_remove_cart_line` (cart-only; upload stays intact).
+13. Cart/order thumbnails: classic cart via `woocommerce_cart_item_thumbnail`; block cart (Store API) via `woocommerce_get_cart_item_from_session` — we clone the cart line's product and `set_image_id()` it to the uploaded attachment, so each line renders its own photo even when several lines share a variation.
+13a. **Lightbox (1.5.4+):** opens `.studiou-fpp-lightbox` with the attachment's `woocommerce_single` size preview. **Primary opener (1.5.7+):** an explicit "Enlarge" button — `.studiou-fpp-card-enlarge` in each card's price-row (sits left of Add-to-Cart, away from the top-right × remove button) and `.studiou-fpp-overview-enlarge` on each overview line. Buttons are wired via direct `element.onclick = onEnlargeClick` at creation/render time so theme-level click interceptors can't swallow them. **Secondary opener:** clicking the thumbnail itself (element-level listener on `.studiou-fpp-card-thumb` via `bindThumbClick`, plus a document-level capture-phase listener). Closed via × button, backdrop click, or Escape. Disabled on cards still uploading. If the overlay DOM somehow fails to initialize, `openLightbox()` falls back to `window.open(url, '_blank')` so the button is never a no-op.
+14. Order admin: thumbnail via `woocommerce_admin_order_item_thumbnail`, download link via `woocommerce_after_order_itemmeta` (auto-marks status as "downloaded").
+15. On checkout, file records linked to the order via `woocommerce_checkout_order_processed` (classic), `woocommerce_store_api_checkout_order_processed` (block), `woocommerce_thankyou` (fallback). Each cart line becomes its own order item with its own photo meta.
+16. **Shortcode path `[studiou_free_photo_product id="X"]`** keeps the single-file 1.3.x UX — one dropzone, one preview, one `attachment_id`, add-to-cart via WC's native `variations_form`. The pvtfw suppression doesn't touch shortcode-embedded products (they're not rendered via `single-product.php`).
 
 ## Coding Conventions
 
@@ -73,10 +95,10 @@ Allows publishing special variable products where customers upload custom raw im
 
 - Generate .mo from .po: `php languages/generate-mo.php` or `wp i18n make-mo languages/`
 - Plugin creates DB table on activation via `register_activation_hook`
-- Activation hook requires explicit `require_once` for DB class (runs before `plugins_loaded`)
+- **Schema upgrades are automatic on load** (no manual migration needed): `maybe_upgrade_db()` calls `create_tables()` → `dbDelta` whenever `studiou_wcfpp_db_version` is older than `STUDIOU_WCFPP_VERSION`. The 1.5.0 upgrade adds the `batch_token` column + index to existing installs transparently.
 - Temp chunks stored in `wp-content/uploads/studiou-fpp-chunks/` (cleaned on deactivation)
 - Assembled files stored in `wp-content/uploads/YYYY/MM/free-photo/`
 
 ## Version Bumping
 
-When changing JS or CSS files, always bump the plugin version in the main PHP file header AND the `STUDIOU_WCFPP_VERSION` constant to bust browser cache.
+When changing JS or CSS files, always bump the plugin version in the main PHP file header AND the `STUDIOU_WCFPP_VERSION` constant to bust browser cache. Also bump the version string in `languages/studiou-wc-free-photo-product.pot` and `languages/studiou-wc-free-photo-product-cs_CZ.po` header, and update the `v…` reference at the top of this CLAUDE.md. A version bump also triggers `maybe_upgrade_db()` → `dbDelta` on next load, which is idempotent.

+ 61 - 0
studiou-wc-free-photo-product/assets/css/admin.css

@@ -153,3 +153,64 @@
 .studiou-fpp-status-solved {
     color: #00a32a;
 }
+
+/* Variation quantity discount tiers editor */
+.studiou-fpp-qty-tiers-field {
+    margin-top: 10px;
+}
+
+.studiou-fpp-qty-tiers-field > label {
+    display: block;
+    font-weight: 600;
+    margin-bottom: 6px;
+}
+
+.studiou-fpp-qty-tiers-table {
+    max-width: 420px;
+    border: 1px solid #ddd;
+    margin-bottom: 6px;
+}
+
+.studiou-fpp-qty-tiers-table thead th {
+    background: #f7f7f7;
+    font-weight: 600;
+    padding: 6px 8px;
+    text-align: left;
+    font-size: 12px;
+}
+
+.studiou-fpp-qty-tiers-table tbody td {
+    padding: 4px 6px;
+    border-top: 1px solid #eee;
+}
+
+.studiou-fpp-qty-tiers-table tbody tr:first-child td {
+    border-top: none;
+}
+
+.studiou-fpp-qty-tiers-table input[type="number"] {
+    width: 100%;
+    min-width: 0;
+    max-width: 110px;
+}
+
+.studiou-fpp-qty-col-from,
+.studiou-fpp-qty-col-pct {
+    width: 45%;
+}
+
+.studiou-fpp-qty-col-remove {
+    width: 10%;
+    text-align: center;
+}
+
+.studiou-fpp-qty-tier-remove {
+    min-height: 26px;
+    line-height: 1 !important;
+    padding: 0 8px !important;
+    color: #b32d2e !important;
+}
+
+.studiou-fpp-qty-tier-add {
+    margin-top: 4px !important;
+}

+ 729 - 0
studiou-wc-free-photo-product/assets/css/frontend.css

@@ -274,6 +274,132 @@
     font-style: italic;
 }
 
+/* Quantity discount pre-selection notice (above variations form) */
+.studiou-fpp-qty-tiers-notice {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 10px 14px;
+    margin: 0 0 15px;
+    background: #fff7e6;
+    border: 1px solid #f0c36d;
+    border-left: 4px solid #dba617;
+    border-radius: 4px;
+    color: #6a4b00;
+    font-size: 0.95em;
+}
+
+.studiou-fpp-qty-tiers-notice .dashicons {
+    color: #dba617;
+    font-size: 20px;
+    width: 20px;
+    height: 20px;
+}
+
+/* Quantity discount tier display */
+.studiou-fpp-qty-tiers-display {
+    margin: 15px 0;
+}
+
+.studiou-fpp-qty-tiers-box {
+    border: 2px solid #2271b1;
+    border-radius: 6px;
+    padding: 14px 18px;
+    background: #f0f7ff;
+}
+
+.studiou-fpp-qty-tiers-box h4 {
+    margin: 0 0 12px;
+    font-size: 1.05em;
+    font-weight: 600;
+    color: #2271b1;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+
+.studiou-fpp-qty-tiers-box h4 .dashicons {
+    font-size: 20px;
+    width: 20px;
+    height: 20px;
+}
+
+.studiou-fpp-qty-tiers-table-display {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 0.9em;
+}
+
+.studiou-fpp-qty-tiers-table-display th,
+.studiou-fpp-qty-tiers-table-display td {
+    padding: 6px 10px;
+    text-align: left;
+    border-bottom: 1px solid #eee;
+}
+
+.studiou-fpp-qty-tiers-table-display thead th {
+    font-weight: 600;
+    color: #666;
+    border-bottom: 2px solid #ddd;
+}
+
+.studiou-fpp-qty-tiers-table-display tbody tr:last-child td {
+    border-bottom: none;
+}
+
+.studiou-fpp-qty-tiers-table-display tbody tr.studiou-fpp-qty-tier-active {
+    background: #e8f4fd;
+    font-weight: 600;
+}
+
+.studiou-fpp-qty-tiers-table-display tbody tr.studiou-fpp-qty-tier-active td {
+    color: #2271b1;
+}
+
+/* Per-row tier display for custom variant-table plugins (pvtfw) */
+.studiou-fpp-row-tiers {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    margin-top: 6px;
+    font-size: 0.8em;
+    color: #555;
+    flex-wrap: wrap;
+}
+
+.studiou-fpp-row-tiers .dashicons {
+    font-size: 14px;
+    width: 14px;
+    height: 14px;
+    color: #dba617;
+}
+
+.studiou-fpp-row-tiers-list {
+    display: inline-flex;
+    gap: 6px;
+    flex-wrap: wrap;
+}
+
+.studiou-fpp-row-tier {
+    background: #fff7e6;
+    border: 1px solid #f0c36d;
+    border-radius: 3px;
+    padding: 1px 6px;
+    white-space: nowrap;
+}
+
+.studiou-fpp-row-save-badge {
+    display: inline-block;
+    margin-left: 6px;
+    padding: 2px 7px;
+    background: #00a32a;
+    color: #fff;
+    border-radius: 3px;
+    font-size: 0.75em;
+    font-weight: 700;
+    vertical-align: middle;
+}
+
 /* Responsive */
 @media (max-width: 600px) {
     .studiou-fpp-product-header {
@@ -294,3 +420,606 @@
         flex-direction: column;
     }
 }
+
+/* ==========================================================================
+   Upload cards stack — 1.5.0 multi-upload layout
+   ========================================================================== */
+
+.studiou-fpp-upload-wrap .studiou-fpp-heading {
+    margin: 20px 0 8px;
+    font-size: 1.2em;
+}
+
+.studiou-fpp-dropzone-disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+    pointer-events: none;
+}
+
+.studiou-fpp-cards {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    margin-top: 20px;
+}
+
+.studiou-fpp-card {
+    position: relative;
+    display: flex;
+    gap: 14px;
+    padding: 14px;
+    border: 1px solid #ddd;
+    border-radius: 6px;
+    background: #fff;
+    box-shadow: 0 1px 2px rgba(0,0,0,0.03);
+}
+
+.studiou-fpp-card.studiou-fpp-card-uploading {
+    opacity: 0.85;
+}
+
+.studiou-fpp-card-remove {
+    position: absolute;
+    top: 6px;
+    right: 8px;
+    width: 26px;
+    height: 26px;
+    line-height: 1;
+    padding: 0;
+    border: 1px solid #d4d4d4;
+    background: #fff;
+    color: #b32d2e;
+    border-radius: 50%;
+    cursor: pointer;
+    font-size: 18px;
+    font-weight: 700;
+}
+
+.studiou-fpp-card-remove:hover {
+    background: #b32d2e;
+    color: #fff;
+    border-color: #b32d2e;
+}
+
+.studiou-fpp-card-thumb {
+    position: relative;
+    flex: 0 0 100px;
+    width: 100px;
+    height: 100px;
+    border-radius: 4px;
+    overflow: hidden;
+    background: #f4f4f4;
+}
+
+.studiou-fpp-card-thumb img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+}
+
+.studiou-fpp-card:not(.studiou-fpp-card-uploading) .studiou-fpp-card-thumb {
+    cursor: zoom-in;
+}
+
+.studiou-fpp-card:not(.studiou-fpp-card-uploading) .studiou-fpp-card-thumb img {
+    transition: opacity 0.15s;
+}
+
+.studiou-fpp-card:not(.studiou-fpp-card-uploading) .studiou-fpp-card-thumb:hover img {
+    opacity: 0.85;
+}
+
+.studiou-fpp-card-thumb-placeholder {
+    width: 100%;
+    height: 100%;
+    background: repeating-linear-gradient(45deg, #eee, #eee 6px, #f6f6f6 6px, #f6f6f6 12px);
+}
+
+.studiou-fpp-card-progress {
+    position: absolute;
+    bottom: 0; left: 0; right: 0;
+    height: 18px;
+    background: rgba(0,0,0,0.55);
+    color: #fff;
+    font-size: 0.75em;
+    text-align: center;
+    line-height: 18px;
+}
+
+.studiou-fpp-card-progress-fill {
+    position: absolute;
+    top: 0; left: 0; bottom: 0;
+    width: 0%;
+    background: #4caf50;
+    transition: width 0.2s ease;
+}
+
+.studiou-fpp-card-progress-text {
+    position: relative;
+    z-index: 1;
+}
+
+.studiou-fpp-card-body {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    min-width: 0;
+}
+
+.studiou-fpp-card-name {
+    font-weight: 600;
+    word-break: break-all;
+    padding-right: 30px;
+}
+
+.studiou-fpp-card-enlarge,
+.studiou-fpp-overview-enlarge {
+    flex: 0 0 auto;
+    padding: 4px 12px;
+    font-size: 0.85em;
+    line-height: 1.4;
+    border: 1px solid #2271b1;
+    background: #fff;
+    color: #2271b1;
+    border-radius: 3px;
+    cursor: pointer;
+    font-weight: 500;
+    white-space: nowrap;
+    position: relative;
+    z-index: 2;
+}
+
+.studiou-fpp-card-enlarge:hover,
+.studiou-fpp-overview-enlarge:hover {
+    background: #2271b1;
+    color: #fff;
+}
+
+.studiou-fpp-card-uploading .studiou-fpp-card-enlarge {
+    opacity: 0.4;
+    pointer-events: none;
+}
+
+.studiou-fpp-overview-name-row {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    flex-wrap: wrap;
+}
+
+.studiou-fpp-card-controls {
+    display: flex;
+    gap: 10px;
+    flex-wrap: wrap;
+    align-items: center;
+}
+
+.studiou-fpp-card-variant {
+    flex: 1 1 auto;
+    min-width: 160px;
+    padding: 6px 8px;
+}
+
+.studiou-fpp-card-qty {
+    display: inline-flex;
+    align-items: stretch;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    overflow: hidden;
+}
+
+.studiou-fpp-card-qty .studiou-fpp-qty-btn {
+    width: 28px;
+    border: none;
+    background: #f6f6f6;
+    cursor: pointer;
+    font-size: 1em;
+    line-height: 1;
+}
+
+.studiou-fpp-card-qty .studiou-fpp-qty-btn:hover {
+    background: #e8e8e8;
+}
+
+.studiou-fpp-card-qty .studiou-fpp-qty-input {
+    width: 48px;
+    text-align: center;
+    border: none;
+    border-left: 1px solid #ccc;
+    border-right: 1px solid #ccc;
+    padding: 4px;
+    -moz-appearance: textfield;
+}
+
+.studiou-fpp-card-qty .studiou-fpp-qty-input::-webkit-outer-spin-button,
+.studiou-fpp-card-qty .studiou-fpp-qty-input::-webkit-inner-spin-button {
+    -webkit-appearance: none;
+    margin: 0;
+}
+
+.studiou-fpp-card-price-row {
+    display: flex;
+    gap: 10px;
+    align-items: center;
+    flex-wrap: wrap;
+    margin-top: auto;
+}
+
+.studiou-fpp-card-price {
+    font-size: 1.05em;
+    font-weight: 600;
+    min-width: 80px;
+}
+
+.studiou-fpp-card-badge {
+    display: inline-block;
+    background: #e6f7e8;
+    color: #1e7e34;
+    padding: 2px 7px;
+    border-radius: 3px;
+    font-size: 0.85em;
+    font-weight: 700;
+}
+
+.studiou-fpp-card-addtocart {
+    margin-left: auto !important;
+}
+
+.studiou-fpp-card-addtocart:disabled {
+    opacity: 0.55;
+    cursor: not-allowed;
+}
+
+/* ==========================================================================
+   Order overview panel
+   ========================================================================== */
+
+.studiou-fpp-overview {
+    margin-top: 30px;
+    padding: 18px;
+    border: 1px solid #e3e3e3;
+    border-radius: 6px;
+    background: #fafafa;
+}
+
+.studiou-fpp-overview-title {
+    margin: 0 0 14px;
+    font-size: 1.15em;
+}
+
+.studiou-fpp-overview-lines {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+
+.studiou-fpp-overview-line {
+    display: flex;
+    gap: 12px;
+    padding: 10px;
+    background: #fff;
+    border: 1px solid #e6e6e6;
+    border-radius: 4px;
+    align-items: center;
+    position: relative;
+}
+
+.studiou-fpp-overview-thumb {
+    width: 56px;
+    height: 56px;
+    object-fit: cover;
+    border-radius: 3px;
+    border: 1px solid #ddd;
+    flex: 0 0 auto;
+    cursor: zoom-in;
+    transition: opacity 0.15s;
+}
+
+.studiou-fpp-overview-thumb:hover {
+    opacity: 0.85;
+}
+
+.studiou-fpp-overview-body {
+    flex: 1;
+    min-width: 0;
+}
+
+.studiou-fpp-overview-name {
+    font-weight: 600;
+    word-break: break-all;
+}
+
+.studiou-fpp-overview-variant {
+    color: #666;
+    font-size: 0.9em;
+    margin-top: 2px;
+}
+
+.studiou-fpp-overview-meta {
+    display: flex;
+    gap: 10px;
+    align-items: center;
+    flex-wrap: wrap;
+    margin-top: 4px;
+}
+
+.studiou-fpp-overview-qty {
+    color: #333;
+    font-size: 0.9em;
+}
+
+.studiou-fpp-overview-price {
+    font-weight: 600;
+}
+
+.studiou-fpp-overview-badge {
+    background: #e6f7e8;
+    color: #1e7e34;
+    padding: 1px 6px;
+    border-radius: 3px;
+    font-size: 0.8em;
+    font-weight: 700;
+}
+
+.studiou-fpp-overview-remove {
+    flex: 0 0 28px;
+    width: 28px;
+    height: 28px;
+    line-height: 26px;
+    text-align: center;
+    font-size: 20px;
+    font-weight: 700;
+    text-decoration: none !important;
+    color: #999;
+    border: 1px solid #ccc;
+    border-radius: 50%;
+}
+
+.studiou-fpp-overview-remove:hover {
+    background: #b32d2e;
+    color: #fff !important;
+    border-color: #b32d2e;
+}
+
+.studiou-fpp-overview-footer {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-top: 14px;
+    padding-top: 14px;
+    border-top: 1px solid #e3e3e3;
+    flex-wrap: wrap;
+    gap: 10px;
+}
+
+.studiou-fpp-overview-subtotal-label {
+    margin-right: 6px;
+    color: #444;
+}
+
+.studiou-fpp-overview-subtotal-value {
+    font-size: 1.1em;
+    font-weight: 700;
+}
+
+.studiou-fpp-overview-checkout {
+    font-weight: 600 !important;
+}
+
+/* Mobile: below 640px the card + overview tighten and controls may wrap */
+@media (max-width: 640px) {
+    .studiou-fpp-card {
+        padding: 10px;
+        gap: 10px;
+    }
+
+    .studiou-fpp-card-thumb {
+        flex-basis: 70px;
+        width: 70px;
+        height: 70px;
+    }
+
+    .studiou-fpp-card-controls {
+        flex-direction: column;
+        align-items: stretch;
+    }
+
+    .studiou-fpp-card-variant {
+        min-width: 0;
+        width: 100%;
+    }
+
+    .studiou-fpp-card-qty {
+        align-self: flex-start;
+    }
+
+    .studiou-fpp-card-price-row {
+        flex-direction: column;
+        align-items: stretch;
+    }
+
+    .studiou-fpp-card-addtocart {
+        margin-left: 0 !important;
+        width: 100%;
+    }
+
+    .studiou-fpp-overview-line {
+        flex-wrap: wrap;
+    }
+
+    .studiou-fpp-overview-remove {
+        position: absolute;
+        top: 6px;
+        right: 6px;
+    }
+}
+
+/* ==========================================================================
+   Lightbox — click a card / overview thumb to view the enlarged preview
+   ========================================================================== */
+
+.studiou-fpp-lightbox {
+    position: fixed;
+    inset: 0;
+    z-index: 100000;
+    background: rgba(0, 0, 0, 0.85);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 40px 24px;
+    box-sizing: border-box;
+}
+
+body.studiou-fpp-lightbox-open {
+    overflow: hidden;
+}
+
+.studiou-fpp-lightbox-inner {
+    max-width: 100%;
+    max-height: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 10px;
+}
+
+.studiou-fpp-lightbox-img {
+    max-width: 100%;
+    max-height: calc(100vh - 120px);
+    width: auto;
+    height: auto;
+    display: block;
+    border-radius: 4px;
+    box-shadow: 0 6px 30px rgba(0, 0, 0, 0.5);
+    background: #fff;
+}
+
+.studiou-fpp-lightbox-caption {
+    color: #fff;
+    font-size: 0.95em;
+    max-width: 90vw;
+    text-align: center;
+    word-break: break-all;
+    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
+}
+
+.studiou-fpp-lightbox-close {
+    position: absolute;
+    top: 14px;
+    right: 18px;
+    width: 40px;
+    height: 40px;
+    background: rgba(255, 255, 255, 0.1);
+    color: #fff;
+    border: 1px solid rgba(255, 255, 255, 0.4);
+    border-radius: 50%;
+    font-size: 26px;
+    line-height: 1;
+    font-weight: 700;
+    cursor: pointer;
+    transition: background 0.15s;
+}
+
+.studiou-fpp-lightbox-close:hover {
+    background: rgba(255, 255, 255, 0.25);
+}
+
+/* ==========================================================================
+   Read-only variant price table (rendered above the upload dropzone, 1.5.12+)
+   ========================================================================== */
+
+.studiou-fpp-price-table-wrap {
+    margin: 18px 0 24px;
+}
+
+.studiou-fpp-price-table-title {
+    margin: 0 0 10px;
+    font-size: 1.15em;
+}
+
+.studiou-fpp-price-table {
+    width: 100%;
+    border-collapse: collapse;
+    border: 1px solid #e3e3e3;
+    background: #fff;
+    font-size: 0.95em;
+}
+
+.studiou-fpp-price-table thead th {
+    background: #f7f7f7;
+    text-align: left;
+    padding: 8px 12px;
+    font-weight: 600;
+    border-bottom: 2px solid #ddd;
+    color: #444;
+}
+
+.studiou-fpp-price-table tbody td {
+    padding: 10px 12px;
+    border-bottom: 1px solid #eee;
+    vertical-align: top;
+}
+
+.studiou-fpp-price-table tbody tr:last-child td {
+    border-bottom: none;
+}
+
+.studiou-fpp-price-table-variant {
+    font-weight: 600;
+}
+
+.studiou-fpp-price-table-price {
+    white-space: nowrap;
+    font-weight: 600;
+}
+
+.studiou-fpp-price-table-row-oos {
+    opacity: 0.5;
+}
+
+.studiou-fpp-price-table-tiers {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+}
+
+.studiou-fpp-price-table-tier {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    background: #fff7e6;
+    border: 1px solid #f0c36d;
+    border-radius: 3px;
+    padding: 1px 7px;
+    font-size: 0.9em;
+    white-space: nowrap;
+}
+
+.studiou-fpp-price-table-tier-qty {
+    color: #6a4b00;
+    font-weight: 600;
+}
+
+.studiou-fpp-price-table-tier-pct {
+    color: #1e7e34;
+    font-weight: 700;
+}
+
+.studiou-fpp-price-table-no-discount {
+    color: #999;
+}
+
+@media (max-width: 640px) {
+    .studiou-fpp-price-table thead th,
+    .studiou-fpp-price-table tbody td {
+        padding: 7px 8px;
+    }
+    .studiou-fpp-price-table-discount {
+        font-size: 0.9em;
+    }
+}

+ 80 - 0
studiou-wc-free-photo-product/assets/js/admin.js

@@ -10,6 +10,11 @@
             initProductTab();
         }
 
+        // ========================================
+        // Product edit page: Variation quantity discount tiers
+        // ========================================
+        initVariationQtyTiers();
+
         // ========================================
         // Admin summary page
         // ========================================
@@ -17,6 +22,47 @@
             initSummaryPage();
         }
 
+        function initVariationQtyTiers() {
+            // Add tier row
+            $(document).on('click', '.studiou-fpp-qty-tier-add', function (e) {
+                e.preventDefault();
+                var $btn = $(this);
+                var loop = $btn.data('loop');
+                var $tbody = $btn.closest('.studiou-fpp-qty-tiers-field').find('.studiou-fpp-qty-tiers-table tbody');
+                var idx = Date.now() + '_' + Math.floor(Math.random() * 10000);
+                var row =
+                    '<tr class="studiou-fpp-qty-tier-row">' +
+                        '<td><input type="number" min="1" step="1" name="studiou_fpp_qty_tiers[' + loop + '][' + idx + '][from_qty]" value="" /></td>' +
+                        '<td><input type="number" min="0" max="100" step="0.01" name="studiou_fpp_qty_tiers[' + loop + '][' + idx + '][percent]" value="" /></td>' +
+                        '<td><button type="button" class="button studiou-fpp-qty-tier-remove">&times;</button></td>' +
+                    '</tr>';
+                $tbody.append(row);
+                markVariationChanged($btn);
+            });
+
+            // Remove tier row
+            $(document).on('click', '.studiou-fpp-qty-tier-remove', function (e) {
+                e.preventDefault();
+                var $btn = $(this);
+                $btn.closest('tr').remove();
+                markVariationChanged($btn);
+            });
+
+            // Mark variation as modified on any input change inside the tier table
+            $(document).on('change input', '.studiou-fpp-qty-tiers-table input', function () {
+                markVariationChanged($(this));
+            });
+
+            function markVariationChanged($el) {
+                var $variation = $el.closest('.woocommerce_variation');
+                if ($variation.length) {
+                    $variation.addClass('variation-needs-update');
+                    $('button.cancel-variation-changes, button.save-variation-changes').removeAttr('disabled');
+                    $('#variable_product_options').trigger('woocommerce_variations_input_changed');
+                }
+            }
+        }
+
         function initProductTab() {
             // Show/hide new media category form
             $('.studiou-fpp-add-media-cat-btn').on('click', function () {
@@ -167,6 +213,8 @@
 
                 if (action === 'download_zip') {
                     bulkDownloadZip(fileIds);
+                } else if (action === 'download_csv') {
+                    bulkDownloadCsv(fileIds);
                 } else if (action === 'delete') {
                     bulkDelete(fileIds);
                 } else if (action.startsWith('status_')) {
@@ -216,6 +264,38 @@
                 });
             }
 
+            function bulkDownloadCsv(fileIds) {
+                showBulkSpinner(true);
+                showAdminNotice(studiouWcfpp.i18n.generatingCsv || studiouWcfpp.i18n.generatingZip, 'info');
+
+                $.ajax({
+                    url: studiouWcfpp.ajaxUrl,
+                    type: 'POST',
+                    data: {
+                        action: 'studiou_wcfpp_download_csv',
+                        nonce: studiouWcfpp.nonce,
+                        file_ids: fileIds
+                    },
+                    success: function (response) {
+                        if (response.success) {
+                            window.location.href = response.data.download_url;
+                            showAdminNotice(
+                                response.data.row_count + ' row(s) exported.',
+                                'success'
+                            );
+                        } else {
+                            showAdminNotice(response.data ? response.data.message : studiouWcfpp.i18n.error, 'error');
+                        }
+                    },
+                    error: function () {
+                        showAdminNotice(studiouWcfpp.i18n.error, 'error');
+                    },
+                    complete: function () {
+                        showBulkSpinner(false);
+                    }
+                });
+            }
+
             function bulkDelete(fileIds) {
                 showBulkSpinner(true);
 

+ 738 - 204
studiou-wc-free-photo-product/assets/js/frontend.js

@@ -3,207 +3,576 @@
 
     $(document).ready(function () {
 
-        var $uploadWrap = $('.studiou-fpp-upload-wrap');
-        if (!$uploadWrap.length) {
+        var $wrap = $('.studiou-fpp-upload-wrap');
+        if (!$wrap.length) {
             return;
         }
 
-        var productId = $uploadWrap.data('product-id');
-        var maxFileSize = parseInt($uploadWrap.data('max-file-size'), 10) || 50;
-        var chunkSize = parseInt(studiouWcfppFront.chunkSize, 10) || 1048576;
-        var uploadedAttachmentId = null;
-        var uploadedFileRecordId = null;
-        var isUploading = false;
-        var originalGalleryImages = {};
-
-        // Upload area elements
-        var $dropzone = $uploadWrap.find('#studiou-fpp-dropzone');
-        var $fileInput = $uploadWrap.find('#studiou-fpp-file-input');
-        var $progress = $uploadWrap.find('.studiou-fpp-progress');
-        var $progressFill = $uploadWrap.find('.studiou-fpp-progress-fill');
-        var $progressText = $uploadWrap.find('.studiou-fpp-progress-text');
-        var $preview = $uploadWrap.find('.studiou-fpp-preview');
-        var $previewImg = $preview.find('img');
-        var $previewName = $uploadWrap.find('.studiou-fpp-preview-name');
-        var $removeBtn = $uploadWrap.find('.studiou-fpp-remove-file');
-        var $messages = $uploadWrap.find('.studiou-fpp-messages');
+        // ==========================================
+        // Config + data
+        // ==========================================
+        var cfg = {
+            productId:   parseInt($wrap.data('product-id'), 10) || 0,
+            maxFileSize: parseInt($wrap.data('max-file-size'), 10) || 50,
+            maxUploads:  parseInt($wrap.data('max-uploads'), 10) || 0,
+            chunkSize:   parseInt(studiouWcfppFront.chunkSize, 10) || 1048576
+        };
+        var i18n = studiouWcfppFront.i18n || {};
+        var data = window.studiouFppCardData || {variants: [], batchFiles: []};
+        var variants = data.variants || [];
+
+        var $dropzone  = $('#studiou-fpp-dropzone');
+        var $fileInput = $('#studiou-fpp-file-input');
+        var $cards     = $('#studiou-fpp-cards');
+        var $messages  = $wrap.find('.studiou-fpp-messages');
+
+        var originalGallery = {};
+
+        // ==========================================
+        // Bootstrap
+        // ==========================================
+        initExistingCards();
+        initDropzone();
+        initCardDelegation();
+        initOverviewDelegation();
+        initLightbox();
+        initOverviewThumbClicks();
+        applyHeroImage(data.heroPreviewUrl);
 
         // ==========================================
-        // Restore session state on page load
+        // Card construction
         // ==========================================
-        if (typeof studiouWcfppSession !== 'undefined' && studiouWcfppSession.attachment_id) {
-            uploadedAttachmentId = studiouWcfppSession.attachment_id;
-            uploadedFileRecordId = studiouWcfppSession.file_record_id;
-            showPreview(studiouWcfppSession.thumbnail_url, studiouWcfppSession.file_name);
-            updateProductGallery(studiouWcfppSession.preview_url || studiouWcfppSession.thumbnail_url);
+        function initExistingCards() {
+            (data.batchFiles || []).forEach(function (f) {
+                var $card = buildCard({
+                    fileRecordId: f.file_record_id,
+                    attachmentId: f.attachment_id,
+                    fileName:     f.file_name,
+                    thumbUrl:     f.thumb_url,
+                    previewUrl:   f.preview_url || f.thumb_url,
+                    variationId:  f.variation_id || 0,
+                    uploading:    false
+                });
+                $cards.append($card);
+            });
+            refreshMaxReachedState();
+        }
+
+        function buildCard(opts) {
+            var $card = $('<div class="studiou-fpp-card"></div>');
+            $card.attr('data-file-record-id', opts.fileRecordId || '');
+            $card.attr('data-attachment-id',  opts.attachmentId || '');
+            if (opts.uploading) $card.addClass('studiou-fpp-card-uploading');
+
+            // Remove-upload button (×)
+            $card.append('<button type="button" class="studiou-fpp-card-remove" title="' +
+                escAttr(i18n.removeFile || 'Remove') + '" aria-label="' +
+                escAttr(i18n.removeFile || 'Remove') + '">&times;</button>');
+
+            // Thumb column
+            var $thumb = $('<div class="studiou-fpp-card-thumb"></div>');
+            if (opts.thumbUrl) {
+                var $img = $('<img>').attr('src', opts.thumbUrl).attr('alt', opts.fileName || '');
+                if (opts.previewUrl) {
+                    $img.attr('data-preview-url', opts.previewUrl);
+                }
+                $thumb.append($img);
+            } else {
+                $thumb.append('<div class="studiou-fpp-card-thumb-placeholder"></div>');
+            }
+            // Element-level click binding — survives even if something intercepts bubble-phase
+            // or capture-phase on document. Fires at target phase for clicks inside $thumb.
+            bindThumbClick($thumb[0]);
+            var $progress = $('<div class="studiou-fpp-card-progress"></div>');
+            $progress.append('<div class="studiou-fpp-card-progress-fill"></div>');
+            $progress.append('<span class="studiou-fpp-card-progress-text">0%</span>');
+            if (!opts.uploading) {
+                $progress.hide();
+            }
+            $thumb.append($progress);
+            $card.append($thumb);
+
+            // Body column
+            var $body = $('<div class="studiou-fpp-card-body"></div>');
+            $body.append($('<div class="studiou-fpp-card-name"></div>').text(opts.fileName || ''));
+
+            var $controls = $('<div class="studiou-fpp-card-controls"></div>');
+            $controls.append(buildVariantSelect(opts.variationId || 0));
+            $controls.append(buildQtyStepper());
+            $body.append($controls);
+
+            var $priceRow = $('<div class="studiou-fpp-card-price-row"></div>');
+            $priceRow.append('<span class="studiou-fpp-card-price">&mdash;</span>');
+            $priceRow.append('<span class="studiou-fpp-card-badge" style="display:none;"></span>');
+            var $enlargeBtn = $('<button type="button" class="studiou-fpp-card-enlarge"></button>').text(i18n.enlarge || 'Enlarge');
+            // Direct target-phase binding — primitive, runs regardless of delegation state.
+            $enlargeBtn[0].onclick = onEnlargeClick;
+            $priceRow.append($enlargeBtn);
+            $priceRow.append('<button type="button" class="button studiou-fpp-card-addtocart">' +
+                escHtml(i18n.addToCart || 'Add to cart') + '</button>');
+            $body.append($priceRow);
+
+            $card.append($body);
+
+            if (!opts.uploading) {
+                updateCardPrice($card);
+            }
+            return $card;
+        }
+
+        function buildVariantSelect(initialVariationId) {
+            var $select = $('<select class="studiou-fpp-card-variant"></select>');
+            $select.append($('<option></option>').val(0).text(i18n.selectVariant || 'Select variant'));
+            for (var i = 0; i < variants.length; i++) {
+                var v = variants[i];
+                // <option> text nodes cannot render HTML, so use a plain-text price formatter
+                var priceLabel = v.base_price ? ' — ' + formatPricePlain(v.base_price) : '';
+                var label = v.label + priceLabel;
+                if (!v.in_stock) {
+                    label += ' — ' + (i18n.outOfStock || 'out of stock');
+                }
+                var $opt = $('<option></option>')
+                    .val(v.id)
+                    .text(label)
+                    .attr('data-base-price', v.base_price);
+                if (!v.in_stock) {
+                    $opt.prop('disabled', true);
+                }
+                $select.append($opt);
+            }
+            if (initialVariationId) {
+                $select.val(initialVariationId);
+            }
+            return $select;
+        }
+
+        function buildQtyStepper() {
+            var $stepper = $('<div class="studiou-fpp-card-qty"></div>');
+            $stepper.append('<button type="button" class="studiou-fpp-qty-btn studiou-fpp-qty-minus">&minus;</button>');
+            $stepper.append('<input type="number" class="studiou-fpp-qty-input" min="1" step="1" value="1">');
+            $stepper.append('<button type="button" class="studiou-fpp-qty-btn studiou-fpp-qty-plus">+</button>');
+            return $stepper;
         }
 
         // ==========================================
-        // Drag and drop
+        // Card live updates
         // ==========================================
-        $dropzone.on('dragover dragenter', function (e) {
-            e.preventDefault();
-            e.stopPropagation();
-            $(this).addClass('studiou-fpp-dragover');
-        });
-
-        $dropzone.on('dragleave drop', function (e) {
-            e.preventDefault();
-            e.stopPropagation();
-            $(this).removeClass('studiou-fpp-dragover');
-        });
-
-        $dropzone.on('drop', function (e) {
-            var files = e.originalEvent.dataTransfer.files;
-            if (files.length > 0) {
-                handleFile(files[0]);
+        function initCardDelegation() {
+            $cards.on('change', '.studiou-fpp-card-variant', function () {
+                updateCardPrice($(this).closest('.studiou-fpp-card'));
+            });
+            $cards.on('input change keyup', '.studiou-fpp-qty-input', function () {
+                updateCardPrice($(this).closest('.studiou-fpp-card'));
+            });
+            $cards.on('click', '.studiou-fpp-qty-plus', function () {
+                var $input = $(this).closest('.studiou-fpp-card-qty').find('.studiou-fpp-qty-input');
+                $input.val((parseInt($input.val(), 10) || 1) + 1).trigger('change');
+            });
+            $cards.on('click', '.studiou-fpp-qty-minus', function () {
+                var $input = $(this).closest('.studiou-fpp-card-qty').find('.studiou-fpp-qty-input');
+                var v = Math.max(1, (parseInt($input.val(), 10) || 1) - 1);
+                $input.val(v).trigger('change');
+            });
+            $cards.on('click', '.studiou-fpp-card-addtocart', function () {
+                onAddToCartClick($(this).closest('.studiou-fpp-card'));
+            });
+            $cards.on('click', '.studiou-fpp-card-remove', function () {
+                onRemoveUploadClick($(this).closest('.studiou-fpp-card'));
+            });
+        }
+
+        function findVariant(variationId) {
+            variationId = parseInt(variationId, 10) || 0;
+            if (!variationId) return null;
+            for (var i = 0; i < variants.length; i++) {
+                if (variants[i].id === variationId) return variants[i];
             }
-        });
+            return null;
+        }
+
+        function resolveTier(tiers, qty) {
+            if (!tiers || !tiers.length) return null;
+            var match = null;
+            for (var i = 0; i < tiers.length; i++) {
+                if (tiers[i].from_qty <= qty) match = tiers[i];
+                else break;
+            }
+            return match;
+        }
 
-        $dropzone.on('click', function () {
-            if (!isUploading) {
-                $fileInput.trigger('click');
+        function updateCardPrice($card) {
+            var $price = $card.find('.studiou-fpp-card-price');
+            var $badge = $card.find('.studiou-fpp-card-badge');
+            var $btn   = $card.find('.studiou-fpp-card-addtocart');
+
+            var variationId = parseInt($card.find('.studiou-fpp-card-variant').val(), 10) || 0;
+            var qty         = Math.max(1, parseInt($card.find('.studiou-fpp-qty-input').val(), 10) || 1);
+            var variant     = findVariant(variationId);
+
+            if (!variant || !variant.base_price) {
+                $price.html('&mdash;');
+                $badge.hide();
+                $btn.prop('disabled', true);
+                return;
             }
-        });
 
-        $fileInput.on('change', function () {
-            if (this.files.length > 0) {
-                handleFile(this.files[0]);
+            var tier = resolveTier(variant.tiers || [], qty);
+            var unit = variant.base_price;
+            if (tier && tier.percent > 0) {
+                unit = variant.base_price * (1 - (tier.percent / 100));
+                $badge.show().text('−' + formatPercent(tier.percent));
+            } else {
+                $badge.hide();
             }
-        });
+            var lineTotal = unit * qty;
+            $price.html(formatPriceHtml(lineTotal));
 
-        // Remove file
-        $removeBtn.on('click', function () {
-            removeUploadedFile();
-        });
+            var uploading = $card.hasClass('studiou-fpp-card-uploading');
+            $btn.prop('disabled', uploading);
+        }
 
         // ==========================================
-        // Upload logic
+        // Upload dropzone
         // ==========================================
-        function handleFile(file) {
-            if (isUploading) return;
+        function initDropzone() {
+            $dropzone.on('dragover dragenter', function (e) {
+                e.preventDefault(); e.stopPropagation();
+                $(this).addClass('studiou-fpp-dragover');
+            });
+            $dropzone.on('dragleave drop', function (e) {
+                e.preventDefault(); e.stopPropagation();
+                $(this).removeClass('studiou-fpp-dragover');
+            });
+            $dropzone.on('drop', function (e) {
+                var files = e.originalEvent.dataTransfer.files;
+                enqueueFiles(files);
+            });
+            $dropzone.on('click', function (e) {
+                if ($dropzone.hasClass('studiou-fpp-dropzone-disabled')) return;
+                // File input lives INSIDE the dropzone, so a click on it bubbles up and
+                // re-enters this handler. On mobile the synthetic .trigger('click') also
+                // bubbles, producing infinite recursion ("Maximum call stack size exceeded").
+                // Ignore clicks that originate on (or bubble from) the input itself.
+                if (e.target === $fileInput[0]) return;
+                // Use the native .click() on the DOM element — it opens the file picker
+                // without dispatching a bubbling jQuery event that would re-trigger us.
+                $fileInput[0].click();
+            });
+            $fileInput.on('click', function (e) {
+                // Defense in depth: stop the click from bubbling back to the dropzone.
+                e.stopPropagation();
+            });
+            $fileInput.on('change', function () {
+                enqueueFiles(this.files);
+                $fileInput.val('');
+            });
+        }
+
+        var uploadQueue = [];
+        var activeUpload = false;
+
+        function enqueueFiles(fileList) {
+            if (!fileList || !fileList.length) return;
+            clearMessages();
+
+            if (cfg.maxUploads > 0) {
+                var existing = $cards.children('.studiou-fpp-card').length;
+                var available = Math.max(0, cfg.maxUploads - existing - uploadQueue.length);
+                if (available <= 0) {
+                    showMessage(formatI18n(i18n.maxUploadsReached, cfg.maxUploads), 'error');
+                    return;
+                }
+                if (fileList.length > available) {
+                    showMessage(formatI18n(i18n.maxUploadsReached, cfg.maxUploads), 'error');
+                }
+                for (var i = 0; i < Math.min(fileList.length, available); i++) {
+                    pushFile(fileList[i]);
+                }
+            } else {
+                for (var j = 0; j < fileList.length; j++) {
+                    pushFile(fileList[j]);
+                }
+            }
+            refreshMaxReachedState();
+            drainQueue();
+        }
 
-            var maxBytes = maxFileSize * 1024 * 1024;
+        function pushFile(file) {
+            var maxBytes = cfg.maxFileSize * 1024 * 1024;
             if (file.size > maxBytes) {
-                showMessage(studiouWcfppFront.i18n.fileTooLarge.replace('%s', maxFileSize), 'error');
+                showMessage(formatI18n(i18n.fileTooLarge, cfg.maxFileSize), 'error');
                 return;
             }
+            var $card = buildCard({
+                fileRecordId: 0,
+                attachmentId: 0,
+                fileName:     file.name,
+                thumbUrl:     '',
+                variationId:  0,
+                uploading:    true
+            });
+            $cards.append($card);
+            uploadQueue.push({file: file, $card: $card});
+        }
 
-            clearMessages();
-            startChunkedUpload(file);
+        function drainQueue() {
+            if (activeUpload || !uploadQueue.length) return;
+            activeUpload = true;
+            var next = uploadQueue.shift();
+            uploadOne(next.file, next.$card, function () {
+                activeUpload = false;
+                drainQueue();
+            });
         }
 
-        function startChunkedUpload(file) {
-            isUploading = true;
-            var totalChunks = Math.ceil(file.size / chunkSize);
-            var uploadId = 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
+        function uploadOne(file, $card, done) {
+            var totalChunks = Math.ceil(file.size / cfg.chunkSize);
+            var uploadId = 'upload_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
             var currentChunk = 0;
-
-            $dropzone.hide();
-            $progress.show();
-
-            function uploadNextChunk() {
-                var start = currentChunk * chunkSize;
-                var end = Math.min(start + chunkSize, file.size);
-                var blob = file.slice(start, end);
-
-                var formData = new FormData();
-                formData.append('action', 'studiou_wcfpp_upload_chunk');
-                formData.append('nonce', studiouWcfppFront.nonce);
-                formData.append('product_id', productId);
-                formData.append('upload_id', uploadId);
-                formData.append('chunk_index', currentChunk);
-                formData.append('total_chunks', totalChunks);
-                formData.append('file_name', file.name);
-                formData.append('file_size', file.size);
-                formData.append('chunk', blob, 'chunk');
-
-                var isLastChunk = (currentChunk === totalChunks - 1);
-                if (isLastChunk) {
-                    $progressFill.css('width', '100%');
-                    $progressText.text(studiouWcfppFront.i18n.processing || 'Processing...');
+            var $fill = $card.find('.studiou-fpp-card-progress-fill');
+            var $text = $card.find('.studiou-fpp-card-progress-text');
+            $card.find('.studiou-fpp-card-progress').show();
+
+            function next() {
+                var start = currentChunk * cfg.chunkSize;
+                var end   = Math.min(start + cfg.chunkSize, file.size);
+                var blob  = file.slice(start, end);
+
+                var form = new FormData();
+                form.append('action',       'studiou_wcfpp_upload_chunk');
+                form.append('nonce',        studiouWcfppFront.nonce);
+                form.append('product_id',   cfg.productId);
+                form.append('upload_id',    uploadId);
+                form.append('chunk_index',  currentChunk);
+                form.append('total_chunks', totalChunks);
+                form.append('file_name',    file.name);
+                form.append('file_size',    file.size);
+                form.append('chunk', blob, 'chunk');
+
+                var isLast = (currentChunk === totalChunks - 1);
+                if (isLast) {
+                    $fill.css('width', '100%');
+                    $text.text(i18n.processing || 'Processing...');
                 }
 
                 $.ajax({
                     url: studiouWcfppFront.ajaxUrl,
                     type: 'POST',
-                    data: formData,
+                    data: form,
                     processData: false,
                     contentType: false,
-                    timeout: isLastChunk ? 120000 : 30000,
+                    timeout: isLast ? 120000 : 30000,
                     success: function (response) {
-                        if (response.success) {
-                            if (!isLastChunk) {
-                                currentChunk++;
-                                var percent = Math.round((currentChunk / totalChunks) * 100);
-                                $progressFill.css('width', percent + '%');
-                                $progressText.text(percent + '%');
-                            }
-
-                            if (response.data.complete) {
-                                uploadedAttachmentId = response.data.attachment_id;
-                                uploadedFileRecordId = response.data.file_record_id;
-
-                                storeInSession(
-                                    response.data.attachment_id,
-                                    response.data.file_record_id,
-                                    response.data.file_name
-                                );
-
-                                showPreview(response.data.thumbnail_url, response.data.file_name);
-                                updateProductGallery(response.data.preview_url || response.data.thumbnail_url);
-                                $progress.hide();
-                                isUploading = false;
-                            } else {
-                                uploadNextChunk();
-                            }
+                        if (!response.success) {
+                            failUpload($card, response.data ? response.data.message : (i18n.uploadError || 'Upload failed.'));
+                            done();
+                            return;
+                        }
+                        if (!response.data.complete) {
+                            currentChunk++;
+                            var pct = Math.round((currentChunk / totalChunks) * 100);
+                            $fill.css('width', pct + '%');
+                            $text.text(pct + '%');
+                            next();
                         } else {
-                            uploadFailed(response.data ? response.data.message : studiouWcfppFront.i18n.uploadError);
+                            finishUpload($card, response.data);
+                            done();
                         }
                     },
                     error: function () {
-                        uploadFailed(studiouWcfppFront.i18n.uploadError);
+                        failUpload($card, i18n.uploadError || 'Upload failed.');
+                        done();
                     }
                 });
             }
+            next();
+        }
+
+        function finishUpload($card, res) {
+            $card.removeClass('studiou-fpp-card-uploading');
+            $card.find('.studiou-fpp-card-progress').hide();
+            $card.attr('data-file-record-id', res.file_record_id);
+            $card.attr('data-attachment-id',  res.attachment_id);
+            var previewUrl = res.preview_url || res.thumbnail_url;
+            var $img = $card.find('.studiou-fpp-card-thumb img');
+            if ($img.length) {
+                $img.attr('src', res.thumbnail_url).attr('data-preview-url', previewUrl);
+            } else {
+                $card.find('.studiou-fpp-card-thumb-placeholder').replaceWith(
+                    $('<img>').attr('src', res.thumbnail_url)
+                              .attr('alt', res.file_name)
+                              .attr('data-preview-url', previewUrl)
+                );
+            }
+            updateCardPrice($card);
+            refreshMaxReachedState();
+
+            // If this is now the first/only card, swap the hero gallery image
+            if ($cards.children('.studiou-fpp-card').index($card) === 0) {
+                applyHeroImage(previewUrl);
+            }
+        }
+
+        function failUpload($card, message) {
+            $card.remove();
+            showMessage(message, 'error');
+            refreshMaxReachedState();
+        }
 
-            uploadNextChunk();
+        function refreshMaxReachedState() {
+            if (cfg.maxUploads <= 0) return;
+            var reached = $cards.children('.studiou-fpp-card').length >= cfg.maxUploads;
+            $dropzone.toggleClass('studiou-fpp-dropzone-disabled', reached);
         }
 
         // ==========================================
-        // Session management
+        // Remove upload (per-card × button)
         // ==========================================
-        function storeInSession(attachmentId, fileRecordId, fileName) {
+        function onRemoveUploadClick($card) {
+            var fileRecordId = parseInt($card.attr('data-file-record-id'), 10) || 0;
+            if (!fileRecordId) {
+                // Still uploading — just cancel locally
+                $card.remove();
+                refreshMaxReachedState();
+                return;
+            }
             $.ajax({
                 url: studiouWcfppFront.ajaxUrl,
                 type: 'POST',
                 data: {
-                    action: 'studiou_wcfpp_set_upload_session',
-                    nonce: studiouWcfppFront.nonce,
-                    attachment_id: attachmentId,
-                    file_record_id: fileRecordId,
-                    file_name: fileName
+                    action: 'studiou_wcfpp_remove_upload',
+                    nonce:  studiouWcfppFront.nonce,
+                    file_record_id: fileRecordId
+                },
+                success: function (response) {
+                    if (response.success) {
+                        var wasFirst = ($cards.children('.studiou-fpp-card').index($card) === 0);
+                        $card.remove();
+                        refreshMaxReachedState();
+
+                        if (wasFirst) {
+                            var $nextFirst = $cards.children('.studiou-fpp-card').first();
+                            if ($nextFirst.length) {
+                                var nextImg = $nextFirst.find('.studiou-fpp-card-thumb img').attr('src');
+                                applyHeroImage(nextImg || '');
+                            } else {
+                                restoreHeroImage();
+                            }
+                        }
+                    } else {
+                        showMessage(response.data ? response.data.message : (i18n.error || 'Error'), 'error');
+                    }
+                },
+                error: function () {
+                    showMessage(i18n.error || 'Error', 'error');
                 }
             });
         }
 
-        function clearSession() {
+        // ==========================================
+        // Add to cart
+        // ==========================================
+        function onAddToCartClick($card) {
+            var fileRecordId = parseInt($card.attr('data-file-record-id'), 10) || 0;
+            var variationId  = parseInt($card.find('.studiou-fpp-card-variant').val(), 10) || 0;
+            var qty          = Math.max(1, parseInt($card.find('.studiou-fpp-qty-input').val(), 10) || 1);
+
+            if (!fileRecordId) {
+                showMessage(i18n.uploadFile || 'Please upload a file.', 'error');
+                return;
+            }
+            if (!variationId) {
+                showMessage(i18n.selectVariant || 'Please select a variant.', 'error');
+                return;
+            }
+
+            var $btn = $card.find('.studiou-fpp-card-addtocart');
+            $btn.prop('disabled', true);
+
             $.ajax({
                 url: studiouWcfppFront.ajaxUrl,
                 type: 'POST',
                 data: {
-                    action: 'studiou_wcfpp_clear_upload_session',
-                    nonce: studiouWcfppFront.nonce
+                    action:         'studiou_wcfpp_add_file_to_cart',
+                    nonce:          studiouWcfppFront.nonce,
+                    file_record_id: fileRecordId,
+                    variation_id:   variationId,
+                    quantity:       qty
+                },
+                success: function (response) {
+                    if (response.success) {
+                        showMessage(i18n.addedToCart || 'Added to cart.', 'success');
+                        if (response.data && response.data.overview_html) {
+                            updateOverview(response.data.overview_html);
+                        }
+                    } else {
+                        showMessage(response.data ? response.data.message : (i18n.addToCartError || 'Could not add to cart.'), 'error');
+                    }
+                },
+                error: function () {
+                    showMessage(i18n.addToCartError || 'Could not add to cart.', 'error');
+                },
+                complete: function () {
+                    $btn.prop('disabled', false);
                 }
             });
         }
 
         // ==========================================
-        // Product gallery image replacement
+        // Order overview
         // ==========================================
-        function updateProductGallery(imageUrl) {
-            if (!imageUrl) return;
+        function initOverviewDelegation() {
+            // Remove-line on overview panel (cart-only, upload stays)
+            $(document).on('click', '.studiou-fpp-overview-remove', function (e) {
+                var $link = $(this);
+                var href = $link.attr('href');
+                // Only intercept if the link has a nonce arg — otherwise let browser navigate
+                if (!href || href.indexOf('studiou-fpp-remove-line') < 0) return;
+                e.preventDefault();
+
+                var $line = $link.closest('.studiou-fpp-overview-line');
+                var key = $line.data('cart-item-key');
+                if (!key) return;
 
+                $.ajax({
+                    url: studiouWcfppFront.ajaxUrl,
+                    type: 'POST',
+                    data: {
+                        action:         'studiou_wcfpp_remove_cart_line',
+                        nonce:          studiouWcfppFront.nonce,
+                        cart_item_key:  key
+                    },
+                    success: function (response) {
+                        if (response.success && response.data && response.data.overview_html) {
+                            updateOverview(response.data.overview_html);
+                        } else if (!response.success) {
+                            showMessage(response.data ? response.data.message : (i18n.error || 'Error'), 'error');
+                        }
+                    },
+                    error: function () {
+                        showMessage(i18n.error || 'Error', 'error');
+                    }
+                });
+            });
+        }
+
+        function updateOverview(html) {
+            var $new = $(html);
+            var $existing = $('#studiou-fpp-overview');
+            if ($existing.length) {
+                $existing.replaceWith($new);
+            } else {
+                $wrap.after($new);
+            }
+            initOverviewThumbClicks();
+            $(document).trigger('studiou-fpp:cart-updated');
+        }
+
+        // ==========================================
+        // Hero image
+        // ==========================================
+        function applyHeroImage(url) {
+            if (!url) return;
             var selectors = [
                 '.woocommerce-product-gallery__image img',
                 '.product .wp-post-image',
@@ -212,7 +581,6 @@
                 '.entry-summary img.attachment-woocommerce_thumbnail',
                 '.product-gallery img:first'
             ];
-
             var $img = null;
             for (var i = 0; i < selectors.length; i++) {
                 $img = $(selectors[i]).first();
@@ -220,22 +588,186 @@
             }
             if (!$img || !$img.length) return;
 
-            if (!originalGalleryImages.src) {
-                originalGalleryImages.src = $img.attr('src');
-                originalGalleryImages.srcset = $img.attr('srcset') || '';
-                originalGalleryImages.sizes = $img.attr('sizes') || '';
+            if (!originalGallery.src) {
+                originalGallery.src    = $img.attr('src');
+                originalGallery.srcset = $img.attr('srcset') || '';
+                originalGallery.sizes  = $img.attr('sizes') || '';
                 var $link = $img.closest('a');
-                if ($link.length) originalGalleryImages.href = $link.attr('href');
+                if ($link.length) originalGallery.href = $link.attr('href');
+            }
+            $img.attr('src', url).removeAttr('srcset').removeAttr('sizes');
+            var $a = $img.closest('a');
+            if ($a.length) $a.attr('href', url);
+        }
+
+        // ==========================================
+        // Lightbox (click thumbnail → enlarged preview)
+        // ==========================================
+        var $lightbox = null;
+
+        // Attach a target-phase click listener directly on a thumbnail element.
+        // Element-level listeners are the most reliable form of click binding — no
+        // delegation, no capture/bubble surprises. Called from buildCard + finishUpload
+        // (upload cards) and from initOverviewThumbClicks (after every overview render).
+        function bindThumbClick(el) {
+            if (!el || el.__studiouFppBound) return;
+            el.__studiouFppBound = true;
+            el.addEventListener('click', function (ev) {
+                var t = ev.target;
+                if (t && t.closest && t.closest('button, input, select, textarea, a')) return;
+
+                var card = el.closest ? el.closest('.studiou-fpp-card') : null;
+                if (card && card.classList.contains('studiou-fpp-card-uploading')) return;
+
+                var img = el.matches && el.matches('.studiou-fpp-overview-thumb')
+                    ? el
+                    : (el.querySelector ? el.querySelector('img') : null);
+                if (!img) return;
+
+                var url = img.getAttribute('data-preview-url') || img.getAttribute('src');
+                if (!url) return;
+
+                var name = '';
+                if (card) {
+                    var nameEl = card.querySelector('.studiou-fpp-card-name');
+                    name = nameEl ? nameEl.textContent : '';
+                } else {
+                    name = img.getAttribute('data-file-name') || img.getAttribute('alt') || '';
+                }
+
+                ev.preventDefault();
+                ev.stopPropagation();
+                openLightbox(url, name);
+            });
+        }
+
+        // Explicit "Enlarge" button handler — survives any parent-level click interceptor
+        // because clicks on <button type="button"> don't match the thumb-image selectors that
+        // theme lightboxes (iLightBox/Magnific) delegate to.
+        function onEnlargeClick(ev) {
+            ev.preventDefault();
+            ev.stopPropagation();
+            var btn  = this;
+            var card = btn.closest('.studiou-fpp-card');
+            var line = btn.closest('.studiou-fpp-overview-line');
+            var img, name;
+            if (card) {
+                img = card.querySelector('.studiou-fpp-card-thumb img');
+                var nameEl = card.querySelector('.studiou-fpp-card-name');
+                name = nameEl ? nameEl.textContent : '';
+            } else if (line) {
+                img = line.querySelector('.studiou-fpp-overview-thumb');
+                name = img ? (img.getAttribute('data-file-name') || img.getAttribute('alt') || '') : '';
+            }
+            if (!img) return;
+            var url = img.getAttribute('data-preview-url') || img.getAttribute('src');
+            if (!url) return;
+            openLightbox(url, name);
+        }
+
+        // (Re)bind all overview thumbs — called after initial render and after updateOverview.
+        function initOverviewThumbClicks() {
+            var nodes = document.querySelectorAll('.studiou-fpp-overview-thumb');
+            for (var i = 0; i < nodes.length; i++) {
+                bindThumbClick(nodes[i]);
+            }
+            var btns = document.querySelectorAll('.studiou-fpp-overview-enlarge');
+            for (var j = 0; j < btns.length; j++) {
+                btns[j].onclick = onEnlargeClick;
             }
+        }
+
+        function initLightbox() {
+            $lightbox = $(
+                '<div class="studiou-fpp-lightbox" role="dialog" aria-modal="true" style="display:none;">' +
+                    '<button type="button" class="studiou-fpp-lightbox-close" aria-label="' + escAttr(i18n.close || 'Close') + '">&times;</button>' +
+                    '<div class="studiou-fpp-lightbox-inner">' +
+                        '<img class="studiou-fpp-lightbox-img" alt="" />' +
+                        '<div class="studiou-fpp-lightbox-caption"></div>' +
+                    '</div>' +
+                '</div>'
+            );
+            $('body').append($lightbox);
+
+            // Enlarge button — primary, reliable opener. Delegated on document so newly
+            // rendered cards and overview lines pick it up automatically.
+            $(document).on('click', '.studiou-fpp-card-enlarge, .studiou-fpp-overview-enlarge', function (e) {
+                onEnlargeClick.call(this, e);
+            });
 
-            $img.attr('src', imageUrl).removeAttr('srcset').removeAttr('sizes');
-            var $link = $img.closest('a');
-            if ($link.length) $link.attr('href', imageUrl);
+            // Use native capture-phase listener so theme/plugin handlers that stopPropagation
+            // on <img> clicks (common in gallery-lightbox plugins) can't swallow us. Target the
+            // .studiou-fpp-card-thumb wrapper, not just the <img>, so any click inside the
+            // thumbnail — including padding around the image — opens the preview.
+            document.addEventListener('click', function (ev) {
+                var target = ev.target;
+                if (!target || !target.closest) return;
+
+                // Ignore clicks on interactive controls (remove button, progress text, etc.)
+                if (target.closest('button, input, select, textarea, a')) return;
+
+                var cardThumb = target.closest('.studiou-fpp-card-thumb');
+                if (cardThumb) {
+                    var card = cardThumb.closest('.studiou-fpp-card');
+                    if (!card || card.classList.contains('studiou-fpp-card-uploading')) return;
+                    var img = cardThumb.querySelector('img');
+                    if (!img) return;
+                    ev.preventDefault();
+                    ev.stopPropagation();
+                    var nameEl = card.querySelector('.studiou-fpp-card-name');
+                    openLightbox(
+                        img.getAttribute('data-preview-url') || img.getAttribute('src'),
+                        nameEl ? nameEl.textContent : ''
+                    );
+                    return;
+                }
+
+                var overviewImg = target.closest('.studiou-fpp-overview-thumb');
+                if (overviewImg) {
+                    ev.preventDefault();
+                    ev.stopPropagation();
+                    openLightbox(
+                        overviewImg.getAttribute('data-preview-url') || overviewImg.getAttribute('src'),
+                        overviewImg.getAttribute('data-file-name') || overviewImg.getAttribute('alt') || ''
+                    );
+                }
+            }, true);
+
+            // Close: × button, backdrop click, ESC key
+            $lightbox.on('click', '.studiou-fpp-lightbox-close', closeLightbox);
+            $lightbox.on('click', function (e) {
+                if (e.target === this) closeLightbox();
+            });
+            $(document).on('keydown.studiouFppLightbox', function (e) {
+                if (e.key === 'Escape' && $lightbox && $lightbox.is(':visible')) {
+                    closeLightbox();
+                }
+            });
+        }
+
+        function openLightbox(url, caption) {
+            if (!url) return;
+            // Fallback: if the overlay DOM somehow failed to initialize, at least open the
+            // preview in a new tab so the button is never a no-op.
+            if (!$lightbox || !$lightbox.length) {
+                window.open(url, '_blank', 'noopener');
+                return;
+            }
+            $lightbox.find('.studiou-fpp-lightbox-img').attr('src', url).attr('alt', caption || '');
+            $lightbox.find('.studiou-fpp-lightbox-caption').text(caption || '');
+            $lightbox.css('display', 'flex');
+            $('body').addClass('studiou-fpp-lightbox-open');
         }
 
-        function restoreProductGallery() {
-            if (!originalGalleryImages.src) return;
+        function closeLightbox() {
+            if (!$lightbox) return;
+            $lightbox.hide();
+            $lightbox.find('.studiou-fpp-lightbox-img').attr('src', '');
+            $('body').removeClass('studiou-fpp-lightbox-open');
+        }
 
+        function restoreHeroImage() {
+            if (!originalGallery.src) return;
             var selectors = [
                 '.woocommerce-product-gallery__image img',
                 '.product .wp-post-image',
@@ -244,87 +776,89 @@
                 '.entry-summary img.attachment-woocommerce_thumbnail',
                 '.product-gallery img:first'
             ];
-
             var $img = null;
             for (var i = 0; i < selectors.length; i++) {
                 $img = $(selectors[i]).first();
                 if ($img.length) break;
             }
             if (!$img || !$img.length) return;
-
-            $img.attr('src', originalGalleryImages.src);
-            if (originalGalleryImages.srcset) $img.attr('srcset', originalGalleryImages.srcset);
-            if (originalGalleryImages.sizes) $img.attr('sizes', originalGalleryImages.sizes);
-            if (originalGalleryImages.href) $img.closest('a').attr('href', originalGalleryImages.href);
-
-            originalGalleryImages = {};
+            $img.attr('src', originalGallery.src);
+            if (originalGallery.srcset) $img.attr('srcset', originalGallery.srcset);
+            if (originalGallery.sizes)  $img.attr('sizes',  originalGallery.sizes);
+            if (originalGallery.href)   $img.closest('a').attr('href', originalGallery.href);
+            originalGallery = {};
         }
 
         // ==========================================
-        // UI helpers
+        // Helpers
         // ==========================================
-        function uploadFailed(message) {
-            isUploading = false;
-            $progress.hide();
-            $dropzone.show();
-            showMessage(message, 'error');
-            $fileInput.val('');
+        function showMessage(text, type) {
+            var cls = type === 'error' ? 'studiou-fpp-msg-error'
+                    : type === 'success' ? 'studiou-fpp-msg-success'
+                    : 'studiou-fpp-msg-info';
+            $messages.html('<div class="studiou-fpp-msg ' + cls + '">' + escHtml(text) + '</div>');
         }
 
-        function showPreview(thumbnailUrl, fileName) {
-            if (thumbnailUrl) {
-                $previewImg.attr('src', thumbnailUrl).show();
-            } else {
-                $previewImg.hide();
-            }
-            $previewName.text(fileName);
-            $preview.show();
-            $dropzone.hide();
+        function clearMessages() { $messages.empty(); }
+
+        function escHtml(str) {
+            var div = document.createElement('div');
+            div.appendChild(document.createTextNode(str == null ? '' : String(str)));
+            return div.innerHTML;
         }
 
-        function removeUploadedFile() {
-            if (!uploadedFileRecordId) return;
+        function escAttr(str) {
+            return String(str == null ? '' : str).replace(/"/g, '&quot;');
+        }
 
-            $.ajax({
-                url: studiouWcfppFront.ajaxUrl,
-                type: 'POST',
-                data: {
-                    action: 'studiou_wcfpp_remove_upload',
-                    nonce: studiouWcfppFront.nonce,
-                    file_record_id: uploadedFileRecordId
-                },
-                success: function () {
-                    resetUpload();
-                }
-            });
+        function formatI18n(tpl, val) {
+            if (!tpl) return String(val);
+            return tpl.replace('%d', val).replace('%s', val);
         }
 
-        function resetUpload() {
-            uploadedAttachmentId = null;
-            uploadedFileRecordId = null;
-            clearSession();
-            restoreProductGallery();
-            $preview.hide();
-            $previewImg.attr('src', '');
-            $previewName.text('');
-            $dropzone.show();
-            $fileInput.val('');
-            clearMessages();
+        function formatPercent(p) {
+            var n = parseFloat(p) || 0;
+            var str = n.toFixed(2).replace(/\.?0+$/, '');
+            return str + ' %';
         }
 
-        function showMessage(text, type) {
-            var cls = type === 'error' ? 'studiou-fpp-msg-error' : 'studiou-fpp-msg-success';
-            $messages.html('<div class="studiou-fpp-msg ' + cls + '">' + escHtml(text) + '</div>');
+        function formatPrice(amount) {
+            return formatPriceHtml(amount);
         }
 
-        function clearMessages() {
-            $messages.empty();
+        // Plain-text price (no HTML) — used for <option> labels where spans aren't rendered
+        function formatPricePlain(amount) {
+            var c = studiouWcfppFront.currency || {};
+            var decimals = parseInt(c.decimals, 10); if (isNaN(decimals)) decimals = 2;
+            var decSep = c.decimalSeparator || '.';
+            var thouSep = c.thousandSeparator || ',';
+            var fmt = c.priceFormat || '%1$s%2$s';
+            var symbol = c.symbol || '';
+            var fixed = parseFloat(amount).toFixed(decimals);
+            var parts = fixed.split('.');
+            var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thouSep);
+            var decPart = parts.length > 1 ? parts[1] : '';
+            var numStr = decPart ? intPart + decSep + decPart : intPart;
+            // strip any HTML tags that might arrive from priceFormat (defensive)
+            return fmt.replace('%1$s', symbol).replace('%2$s', numStr).replace(/<[^>]*>/g, '');
         }
 
-        function escHtml(str) {
-            var div = document.createElement('div');
-            div.appendChild(document.createTextNode(str));
-            return div.innerHTML;
+        function formatPriceHtml(amount) {
+            var c = studiouWcfppFront.currency || {};
+            var decimals = parseInt(c.decimals, 10); if (isNaN(decimals)) decimals = 2;
+            var decSep = c.decimalSeparator || '.';
+            var thouSep = c.thousandSeparator || ',';
+            var fmt = c.priceFormat || '%1$s%2$s';
+            var symbol = c.symbol || '';
+            var fixed = parseFloat(amount).toFixed(decimals);
+            var parts = fixed.split('.');
+            var intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thouSep);
+            var decPart = parts.length > 1 ? parts[1] : '';
+            var numStr = decPart ? intPart + decSep + decPart : intPart;
+            var sym = '<span class="woocommerce-Price-currencySymbol">' + symbol + '</span>';
+            return '<span class="woocommerce-Price-amount amount">' +
+                fmt.replace('%1$s', sym).replace('%2$s', numStr) +
+                '</span>';
         }
     });
 })(jQuery);

+ 157 - 16
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-admin.php

@@ -15,38 +15,52 @@ class Studiou_WC_FPP_Admin {
         add_action('wp_ajax_studiou_wcfpp_update_status', array($this, 'ajax_update_status'));
         add_action('wp_ajax_studiou_wcfpp_delete_files', array($this, 'ajax_delete_files'));
         add_action('wp_ajax_studiou_wcfpp_download_zip', array($this, 'ajax_download_zip'));
+        add_action('wp_ajax_studiou_wcfpp_download_csv', array($this, 'ajax_download_csv'));
         add_action('wp_ajax_studiou_wcfpp_bulk_status', array($this, 'ajax_bulk_status'));
 
-        // Handle direct ZIP download and file download (marks as downloaded)
+        // Handle direct ZIP / CSV download and file download (marks as downloaded)
         add_action('admin_init', array($this, 'handle_zip_download'));
+        add_action('admin_init', array($this, 'handle_csv_download'));
         add_action('admin_init', array($this, 'handle_file_download'));
     }
 
     public function render_summary_page() {
         // Process filters
-        $current_status = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
+        $current_status  = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : '';
         $current_product = isset($_GET['product_id']) ? absint($_GET['product_id']) : 0;
-        $current_search = isset($_GET['s']) ? sanitize_text_field($_GET['s']) : '';
-        $current_page = isset($_GET['paged']) ? max(1, absint($_GET['paged'])) : 1;
-        $per_page = 30;
+        $current_order   = isset($_GET['order_id']) ? absint($_GET['order_id']) : 0;
+        $current_search  = isset($_GET['s']) ? sanitize_text_field($_GET['s']) : '';
+        $current_page    = isset($_GET['paged']) ? max(1, absint($_GET['paged'])) : 1;
+        $per_page        = 30;
+
+        $allowed_orderby = array('order_id', 'status', 'created_at');
+        $orderby_in      = isset($_GET['orderby']) ? sanitize_text_field($_GET['orderby']) : 'created_at';
+        $orderby         = in_array($orderby_in, $allowed_orderby, true) ? $orderby_in : 'created_at';
+        $order_in        = isset($_GET['order']) ? strtoupper(sanitize_text_field($_GET['order'])) : 'DESC';
+        $order           = ($order_in === 'ASC') ? 'ASC' : 'DESC';
 
         $args = array(
-            'status'  => $current_status,
+            'status'     => $current_status,
             'product_id' => $current_product,
-            'search'  => $current_search,
-            'limit'   => $per_page,
-            'offset'  => ($current_page - 1) * $per_page,
-            'orderby' => isset($_GET['orderby']) ? sanitize_text_field($_GET['orderby']) : 'created_at',
-            'order'   => isset($_GET['order']) ? sanitize_text_field($_GET['order']) : 'DESC',
+            'order_id'   => $current_order,
+            'search'     => $current_search,
+            'limit'      => $per_page,
+            'offset'     => ($current_page - 1) * $per_page,
+            'orderby'    => $orderby,
+            'order'      => $order,
         );
 
-        $records = $this->db->get_file_records($args);
-        $total = $this->db->count_file_records($args);
+        $records     = $this->db->get_file_records($args);
+        $total       = $this->db->count_file_records($args);
         $total_pages = ceil($total / $per_page);
 
         // Get products that have FPP enabled for filter dropdown
         $fpp_products = $this->get_fpp_products();
 
+        // Expose active sort to the view
+        $current_orderby = $orderby;
+        $current_order_dir = $order;
+
         include STUDIOU_WCFPP_PLUGIN_DIR . 'views/admin-summary.php';
     }
 
@@ -188,7 +202,10 @@ class Studiou_WC_FPP_Admin {
 
         $added = 0;
         foreach ($records as $record) {
-            $file_path = get_attached_file($record->attachment_id);
+            $file_path = wp_get_original_image_path($record->attachment_id);
+            if (!$file_path) {
+                $file_path = get_attached_file($record->attachment_id);
+            }
             if ($file_path && file_exists($file_path)) {
                 // Use order number prefix if available
                 $prefix = $record->order_id ? 'order-' . $record->order_id . '_' : '';
@@ -252,6 +269,127 @@ class Studiou_WC_FPP_Admin {
         exit;
     }
 
+    public function ajax_download_csv() {
+        check_ajax_referer('studiou-wcfpp-nonce', 'nonce');
+
+        if (!current_user_can('manage_woocommerce')) {
+            wp_send_json_error(array('message' => __('Insufficient permissions.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $file_ids = isset($_POST['file_ids']) ? array_map('absint', (array) $_POST['file_ids']) : array();
+        if (empty($file_ids)) {
+            wp_send_json_error(array('message' => __('No files selected.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $records = $this->db->get_file_records_by_ids($file_ids);
+        if (empty($records)) {
+            wp_send_json_error(array('message' => __('No files found.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $upload_dir = wp_upload_dir();
+        $csv_name   = 'fpp-records-' . date('Y-m-d-His') . '.csv';
+        $csv_dir    = $upload_dir['basedir'] . '/studiou-fpp-chunks';
+        if (!file_exists($csv_dir)) {
+            wp_mkdir_p($csv_dir);
+        }
+        $csv_path = $csv_dir . '/' . $csv_name;
+
+        $fh = fopen($csv_path, 'wb');
+        if (!$fh) {
+            wp_send_json_error(array('message' => __('Could not create CSV file.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        // UTF-8 BOM so Excel opens the file with the correct encoding
+        fwrite($fh, "\xEF\xBB\xBF");
+
+        // Header row — column labels translated
+        fputcsv($fh, array(
+            __('ID', 'studiou-wc-free-photo-product'),
+            __('File Name', 'studiou-wc-free-photo-product'),
+            __('Product', 'studiou-wc-free-photo-product'),
+            __('Variation ID', 'studiou-wc-free-photo-product'),
+            __('Order', 'studiou-wc-free-photo-product'),
+            __('Customer', 'studiou-wc-free-photo-product'),
+            __('Status', 'studiou-wc-free-photo-product'),
+            __('Date', 'studiou-wc-free-photo-product'),
+            __('File URL', 'studiou-wc-free-photo-product'),
+        ));
+
+        foreach ($records as $record) {
+            $product_title = get_the_title($record->product_id);
+
+            if ((int) $record->customer_id > 0) {
+                $user = get_userdata((int) $record->customer_id);
+                $customer_name = $user ? $user->display_name : '#' . (int) $record->customer_id;
+            } else {
+                $customer_name = __('Guest', 'studiou-wc-free-photo-product');
+            }
+
+            $file_url = wp_get_attachment_url((int) $record->attachment_id);
+
+            fputcsv($fh, array(
+                (int) $record->id,
+                (string) $record->file_name,
+                (string) $product_title,
+                (int) $record->variation_id,
+                (int) $record->order_id ? '#' . (int) $record->order_id : '',
+                (string) $customer_name,
+                (string) self::get_status_label($record->status),
+                (string) $record->created_at,
+                (string) ($file_url ?: ''),
+            ));
+        }
+
+        fclose($fh);
+
+        $download_url = add_query_arg(array(
+            'studiou_fpp_download_csv' => 1,
+            'file'     => $csv_name,
+            '_wpnonce' => wp_create_nonce('studiou-fpp-csv-download'),
+        ), admin_url('admin.php'));
+
+        wp_send_json_success(array(
+            'download_url' => $download_url,
+            'row_count'    => count($records),
+        ));
+    }
+
+    public function handle_csv_download() {
+        if (!isset($_GET['studiou_fpp_download_csv']) || !isset($_GET['file'])) {
+            return;
+        }
+
+        if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'studiou-fpp-csv-download')) {
+            wp_die(__('Security check failed.', 'studiou-wc-free-photo-product'));
+        }
+
+        if (!current_user_can('manage_woocommerce')) {
+            wp_die(__('Insufficient permissions.', 'studiou-wc-free-photo-product'));
+        }
+
+        $file_name  = sanitize_file_name($_GET['file']);
+        $upload_dir = wp_upload_dir();
+        $file_path  = $upload_dir['basedir'] . '/studiou-fpp-chunks/' . $file_name;
+
+        if (!file_exists($file_path) || pathinfo($file_name, PATHINFO_EXTENSION) !== 'csv') {
+            wp_die(__('File not found.', 'studiou-wc-free-photo-product'));
+        }
+
+        header('Content-Type: text/csv; charset=UTF-8');
+        header('Content-Disposition: attachment; filename="' . $file_name . '"');
+        header('Content-Length: ' . filesize($file_path));
+        header('Pragma: no-cache');
+
+        readfile($file_path);
+
+        unlink($file_path);
+        exit;
+    }
+
     /**
      * Handle single file download — serves the file and marks status as "downloaded".
      */
@@ -283,8 +421,11 @@ class Studiou_WC_FPP_Admin {
             }
         }
 
-        // Serve the file
-        $file_path = get_attached_file($attachment_id);
+        // Serve the original (unscaled) file — WP auto-scales images above the big-image threshold.
+        $file_path = wp_get_original_image_path($attachment_id);
+        if (!$file_path) {
+            $file_path = get_attached_file($attachment_id);
+        }
         if (!$file_path || !file_exists($file_path)) {
             wp_die(__('File not found.', 'studiou-wc-free-photo-product'));
         }

+ 180 - 49
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-cart.php

@@ -11,6 +11,12 @@ class Studiou_WC_FPP_Cart {
     public function __construct($db) {
         $this->db = $db;
 
+        // New per-card add-to-cart / remove-line endpoints (1.5.0)
+        add_action('wp_ajax_studiou_wcfpp_add_file_to_cart',        array($this, 'ajax_add_file_to_cart'));
+        add_action('wp_ajax_nopriv_studiou_wcfpp_add_file_to_cart', array($this, 'ajax_add_file_to_cart'));
+        add_action('wp_ajax_studiou_wcfpp_remove_cart_line',        array($this, 'ajax_remove_cart_line'));
+        add_action('wp_ajax_nopriv_studiou_wcfpp_remove_cart_line', array($this, 'ajax_remove_cart_line'));
+
         // Capture file data from WC session on add-to-cart
         add_filter('woocommerce_add_cart_item_data', array($this, 'add_cart_item_data'), 10, 3);
 
@@ -23,9 +29,10 @@ class Studiou_WC_FPP_Cart {
         // Show uploaded photo preview directly after item name in cart (classic cart)
         add_action('woocommerce_after_cart_item_name', array($this, 'show_cart_item_photo_preview'), 10, 2);
 
-        // Override product image for block cart (Store API) — returns uploaded photo as product image
-        add_filter('woocommerce_product_get_image_id', array($this, 'override_product_image_for_cart'), 10, 2);
-        add_filter('woocommerce_product_variation_get_image_id', array($this, 'override_product_image_for_cart'), 10, 2);
+        // Block cart (Store API) reads the thumbnail from $cart_item['data']->get_image_id(). Stamp
+        // the uploaded attachment onto a per-line clone of the product so each cart line shows its
+        // OWN photo — even when several lines share the same variation_id.
+        add_filter('woocommerce_get_cart_item_from_session', array($this, 'hydrate_cart_item_image'), 10, 3);
 
         // Save file reference to order item
         add_action('woocommerce_checkout_create_order_line_item', array($this, 'save_order_item_meta'), 10, 4);
@@ -54,39 +61,167 @@ class Studiou_WC_FPP_Cart {
             }
         }
 
-        // Read file data from WC session
-        if (!WC()->session) {
+        // 1) New path (1.5.0+): per-card Add-to-Cart pre-stamped the file_record_id.
+        if (!empty($cart_item_data['studiou_fpp_file_record_id'])) {
+            $record = $this->db->get_file_record((int) $cart_item_data['studiou_fpp_file_record_id']);
+            if ($record && (int) $record->order_id === 0) {
+                $thumb = wp_get_attachment_image_src((int) $record->attachment_id, 'thumbnail');
+                $cart_item_data['studiou_fpp_attachment_id'] = (int) $record->attachment_id;
+                $cart_item_data['studiou_fpp_file_name']     = (string) $record->file_name;
+                $cart_item_data['studiou_fpp_thumb_url']     = $thumb ? $thumb[0] : '';
+
+                $this->db->update_file_record((int) $record->id, array(
+                    'variation_id' => (int) ($variation_id ?: $product_id),
+                ));
+                return $cart_item_data;
+            }
+            // Stamped id is bogus — fall through to legacy resolver
+            unset($cart_item_data['studiou_fpp_file_record_id']);
+        }
+
+        // 2) Legacy single-file path (shortcode / non-card callers) — fall back to the
+        //    oldest unbound upload in the current batch.
+        $resolved = Studiou_WC_FPP_Single_Product::resolve_uploaded_file($this->db);
+        if (!$resolved) {
             return $cart_item_data;
         }
 
-        $attachment_id = WC()->session->get('studiou_fpp_attachment_id');
-        $file_record_id = WC()->session->get('studiou_fpp_file_record_id');
-        $file_name = WC()->session->get('studiou_fpp_file_name');
-        $thumb_url = WC()->session->get('studiou_fpp_thumb_url');
+        $cart_item_data['studiou_fpp_attachment_id']  = (int) $resolved['attachment_id'];
+        $cart_item_data['studiou_fpp_file_record_id'] = (int) $resolved['file_record_id'];
+        $cart_item_data['studiou_fpp_file_name']      = $resolved['file_name'];
+        $cart_item_data['studiou_fpp_thumb_url']      = $resolved['thumb_url'];
 
-        if (!$attachment_id || !$file_record_id) {
-            return $cart_item_data;
+        $this->db->update_file_record((int) $resolved['file_record_id'], array(
+            'variation_id' => (int) ($variation_id ?: $product_id),
+        ));
+        return $cart_item_data;
+    }
+
+    /**
+     * AJAX: add one (upload × variant × qty) triple as a new cart line.
+     * Called from the upload-card "Add to Cart" button.
+     */
+    public function ajax_add_file_to_cart() {
+        check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
+
+        $file_record_id = isset($_POST['file_record_id']) ? absint($_POST['file_record_id']) : 0;
+        $variation_id   = isset($_POST['variation_id'])   ? absint($_POST['variation_id'])   : 0;
+        $quantity       = isset($_POST['quantity'])       ? max(1, absint($_POST['quantity'])) : 1;
+
+        if (!$file_record_id || !$variation_id) {
+            wp_send_json_error(array('message' => __('Invalid request.', 'studiou-wc-free-photo-product')));
+            return;
         }
 
-        // Verify the file record exists
         $record = $this->db->get_file_record($file_record_id);
-        if (!$record || (int) $record->attachment_id !== (int) $attachment_id) {
-            return $cart_item_data;
+        if (!$record || (int) $record->order_id !== 0) {
+            wp_send_json_error(array('message' => __('File not found.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        // Ownership check: record must belong to the caller's batch
+        $token = Studiou_WC_FPP_Upload::get_batch_token();
+        if (!empty($token) && !empty($record->batch_token) && $token !== $record->batch_token) {
+            wp_send_json_error(array('message' => __('Permission denied.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $variation = wc_get_product($variation_id);
+        if (!$variation || $variation->get_type() !== 'variation') {
+            wp_send_json_error(array('message' => __('Invalid variant.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+        $parent_id = $variation->get_parent_id();
+        if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+            wp_send_json_error(array('message' => __('Invalid product.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        if (function_exists('wc_load_cart')) {
+            wc_load_cart();
+        }
+        if (!WC()->cart) {
+            wp_send_json_error(array('message' => __('Cart unavailable.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $variation_attrs = $variation->get_variation_attributes();
+        $cart_item_data  = array('studiou_fpp_file_record_id' => (int) $file_record_id);
+
+        $cart_key = WC()->cart->add_to_cart($parent_id, $quantity, $variation_id, $variation_attrs, $cart_item_data);
+        if (!$cart_key) {
+            // WC emits notices on failure — surface the first error if available
+            $notices = function_exists('wc_get_notices') ? wc_get_notices('error') : array();
+            $msg = __('Could not add to cart.', 'studiou-wc-free-photo-product');
+            if (!empty($notices[0]['notice'])) {
+                $msg = wp_strip_all_tags($notices[0]['notice']);
+            }
+            if (function_exists('wc_clear_notices')) {
+                wc_clear_notices();
+            }
+            wp_send_json_error(array('message' => $msg));
+            return;
+        }
+
+        wp_send_json_success(array(
+            'cart_key'      => $cart_key,
+            'overview_html' => $this->render_overview_html($parent_id),
+        ));
+    }
+
+    /**
+     * AJAX: remove one cart line (from the order-overview panel).
+     * Does NOT delete the upload — the × on the upload card handles that.
+     */
+    public function ajax_remove_cart_line() {
+        check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
+
+        $cart_item_key = isset($_POST['cart_item_key']) ? sanitize_text_field($_POST['cart_item_key']) : '';
+        if (!$cart_item_key) {
+            wp_send_json_error(array('message' => __('Invalid request.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        if (function_exists('wc_load_cart')) {
+            wc_load_cart();
+        }
+        if (!WC()->cart) {
+            wp_send_json_error(array('message' => __('Cart unavailable.', 'studiou-wc-free-photo-product')));
+            return;
+        }
+
+        $item = WC()->cart->get_cart_item($cart_item_key);
+        if (!$item) {
+            wp_send_json_error(array('message' => __('Cart line not found.', 'studiou-wc-free-photo-product')));
+            return;
         }
 
-        $cart_item_data['studiou_fpp_attachment_id'] = $attachment_id;
-        $cart_item_data['studiou_fpp_file_record_id'] = $file_record_id;
-        $cart_item_data['studiou_fpp_file_name'] = $file_name ?: $record->file_name;
-        $cart_item_data['studiou_fpp_thumb_url'] = $thumb_url ?: '';
+        $product_id = (int) ($item['product_id'] ?? 0);
+        WC()->cart->remove_cart_item($cart_item_key);
 
-        // Update file record with variation
-        $actual_variation_id = $variation_id ?: $product_id;
-        $this->db->update_file_record($file_record_id, array(
-            'variation_id' => $actual_variation_id,
+        wp_send_json_success(array(
+            'overview_html' => $this->render_overview_html($product_id),
         ));
+    }
 
-        // Keep session data so the same photo can be used for multiple variants
-        return $cart_item_data;
+    /**
+     * Render the order-overview panel for the given product. Returns the HTML
+     * so the JS can replace the in-page panel.
+     */
+    private function render_overview_html($product_id) {
+        if (!$product_id) {
+            return '';
+        }
+        $cart = WC()->cart;
+        if (!$cart) {
+            return '';
+        }
+        // Force a totals recalculation so the lines reflect the tier discount
+        $cart->calculate_totals();
+
+        ob_start();
+        include STUDIOU_WCFPP_PLUGIN_DIR . 'views/single-product-order-overview.php';
+        return ob_get_clean();
     }
 
     public function cart_item_thumbnail($thumbnail, $cart_item, $cart_item_key) {
@@ -99,34 +234,30 @@ class Studiou_WC_FPP_Cart {
     }
 
     /**
-     * Override product/variation image_id when rendered in cart context (block cart / Store API).
-     * Returns the uploaded photo attachment_id so the block cart shows it instead of placeholder.
+     * Hydrate each cart line with its own product instance carrying the uploaded image_id.
+     *
+     * Fires on session restore (both classic cart render and Store API block cart). Because the
+     * product instance is cloned per line, setting `set_image_id()` only affects THIS line's
+     * display — multiple cart lines sharing the same variation_id still render their own photos.
+     *
+     * Previous implementation hooked `woocommerce_product_get_image_id` which fires per-product
+     * and couldn't tell which line was currently being rendered; when two lines shared a
+     * variation it returned the first match for both, causing mismatched thumbs in block cart.
      */
-    public function override_product_image_for_cart($image_id, $product) {
-        if (!WC()->cart) {
-            return $image_id;
+    public function hydrate_cart_item_image($cart_item, $values, $cart_item_key) {
+        if (empty($cart_item['studiou_fpp_attachment_id'])) {
+            return $cart_item;
         }
-
-        $product_id = $product->get_id();
-
-        foreach (WC()->cart->get_cart() as $cart_item) {
-            if (empty($cart_item['studiou_fpp_attachment_id'])) {
-                continue;
-            }
-
-            $match = false;
-            if (isset($cart_item['variation_id']) && $cart_item['variation_id'] == $product_id) {
-                $match = true;
-            } elseif ($cart_item['product_id'] == $product_id) {
-                $match = true;
-            }
-
-            if ($match) {
-                return (int) $cart_item['studiou_fpp_attachment_id'];
-            }
+        if (empty($cart_item['data']) || !is_object($cart_item['data'])) {
+            return $cart_item;
         }
-
-        return $image_id;
+        if (!method_exists($cart_item['data'], 'set_image_id')) {
+            return $cart_item;
+        }
+        $product = clone $cart_item['data'];
+        $product->set_image_id((int) $cart_item['studiou_fpp_attachment_id']);
+        $cart_item['data'] = $product;
+        return $cart_item;
     }
 
     public function display_cart_item_data($item_data, $cart_item) {

+ 60 - 2
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-db.php

@@ -61,6 +61,7 @@ class Studiou_WC_FPP_DB {
             attachment_id bigint(20) unsigned NOT NULL,
             customer_id bigint(20) unsigned DEFAULT 0,
             session_key varchar(64) DEFAULT '',
+            batch_token varchar(32) NOT NULL DEFAULT '',
             file_name varchar(255) NOT NULL,
             status varchar(20) NOT NULL DEFAULT 'ready',
             created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -70,7 +71,8 @@ class Studiou_WC_FPP_DB {
             KEY order_id (order_id),
             KEY attachment_id (attachment_id),
             KEY status (status),
-            KEY customer_id (customer_id)
+            KEY customer_id (customer_id),
+            KEY batch_token (batch_token)
         ) {$charset_collate};";
 
         require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
@@ -90,6 +92,7 @@ class Studiou_WC_FPP_DB {
             'attachment_id' => 0,
             'customer_id'   => get_current_user_id(),
             'session_key'   => '',
+            'batch_token'   => '',
             'file_name'     => '',
             'status'        => 'ready',
             'created_at'    => current_time('mysql'),
@@ -101,7 +104,7 @@ class Studiou_WC_FPP_DB {
         $result = $wpdb->insert(
             $this->get_table_name(),
             $data,
-            array('%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s')
+            array('%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s')
         );
 
         if ($result === false) {
@@ -261,6 +264,61 @@ class Studiou_WC_FPP_DB {
         return $wpdb->get_results($sql);
     }
 
+    /**
+     * Return all file records belonging to a batch token that are not yet bound to an order.
+     *
+     * @param string $batch_token
+     * @param int    $product_id  Optional — restrict to a single product_id.
+     * @return array of row objects, oldest first (ascending id).
+     */
+    public function get_batch_files($batch_token, $product_id = 0) {
+        global $wpdb;
+        if (empty($batch_token)) {
+            return array();
+        }
+        $table = $this->get_table_name();
+
+        if ((int) $product_id > 0) {
+            $sql = $wpdb->prepare(
+                "SELECT * FROM {$table}
+                 WHERE batch_token = %s
+                   AND order_id = 0
+                   AND product_id = %d
+                 ORDER BY id ASC",
+                $batch_token,
+                (int) $product_id
+            );
+        } else {
+            $sql = $wpdb->prepare(
+                "SELECT * FROM {$table}
+                 WHERE batch_token = %s
+                   AND order_id = 0
+                 ORDER BY id ASC",
+                $batch_token
+            );
+        }
+        return $wpdb->get_results($sql);
+    }
+
+    /**
+     * Count batch files for a product (enforces per-product max-uploads cap).
+     */
+    public function count_batch_files($batch_token, $product_id) {
+        global $wpdb;
+        if (empty($batch_token) || (int) $product_id <= 0) {
+            return 0;
+        }
+        $table = $this->get_table_name();
+        return (int) $wpdb->get_var($wpdb->prepare(
+            "SELECT COUNT(*) FROM {$table}
+             WHERE batch_token = %s
+               AND order_id = 0
+               AND product_id = %d",
+            $batch_token,
+            (int) $product_id
+        ));
+    }
+
     public function link_file_to_order($file_record_id, $order_id, $order_item_id) {
         return $this->update_file_record($file_record_id, array(
             'order_id'      => $order_id,

+ 342 - 0
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-pricing.php

@@ -0,0 +1,342 @@
+<?php
+if (!defined('WPINC')) {
+    die;
+}
+
+class Studiou_WC_FPP_Pricing {
+
+    const META_KEY = '_studiou_fpp_qty_tiers';
+
+    public function __construct() {
+        // Admin: render tier editor inside each variation panel
+        add_action('woocommerce_variation_options_pricing', array($this, 'render_variation_tier_editor'), 20, 3);
+
+        // Admin: save tiers per variation
+        add_action('woocommerce_save_product_variation', array($this, 'save_variation_tiers'), 10, 2);
+
+        // Frontend: expose tiers on the variations data emitted for the variations form
+        add_filter('woocommerce_available_variation', array($this, 'inject_variation_tier_data'), 10, 3);
+
+        // Frontend: render placeholder container on the single product page
+        add_action('woocommerce_single_product_summary', array($this, 'render_tier_display_placeholder'), 34);
+
+        // Frontend: render pre-selection notice — uses summary hook so it works with any
+        // add-to-cart UI (WC native variations form, pvtfw/product-variant-table, etc.)
+        add_action('woocommerce_single_product_summary', array($this, 'render_preform_notice'), 25);
+
+        // Frontend: emit tier data as an inline JSON blob so JS can match rows of custom
+        // variant-table plugins to their tier configuration.
+        add_action('wp_footer', array($this, 'emit_tier_data_script'));
+
+        // Cart runtime: apply discounted unit price based on line quantity
+        add_action('woocommerce_before_calculate_totals', array($this, 'apply_cart_discount'), 20, 1);
+    }
+
+    // ============================================================
+    // Data access
+    // ============================================================
+
+    public static function get_tiers($variation_id) {
+        $raw = get_post_meta((int) $variation_id, self::META_KEY, true);
+        if (empty($raw) || !is_array($raw)) {
+            return array();
+        }
+        return self::normalize_tiers($raw);
+    }
+
+    private static function normalize_tiers(array $tiers) {
+        $out = array();
+        foreach ($tiers as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            $from = isset($row['from_qty']) ? (int) $row['from_qty'] : 0;
+            if ($from < 1) {
+                continue;
+            }
+            $pct = isset($row['percent']) ? (float) $row['percent'] : 0.0;
+            $pct = max(0.0, min(100.0, $pct));
+            $out[$from] = array('from_qty' => $from, 'percent' => $pct);
+        }
+        ksort($out);
+        return array_values($out);
+    }
+
+    public static function resolve_tier(array $tiers, $qty) {
+        $qty = (int) $qty;
+        if ($qty < 1 || empty($tiers)) {
+            return null;
+        }
+        $match = null;
+        foreach ($tiers as $tier) {
+            if ($tier['from_qty'] <= $qty) {
+                $match = $tier;
+            } else {
+                break;
+            }
+        }
+        return $match;
+    }
+
+    public static function compute_discounted_price($base_price, array $tiers, $qty) {
+        $base = (float) $base_price;
+        if ($base <= 0.0 || empty($tiers)) {
+            return $base;
+        }
+        $tier = self::resolve_tier($tiers, $qty);
+        if (!$tier || $tier['percent'] <= 0.0) {
+            return $base;
+        }
+        $discounted = $base * (1.0 - ($tier['percent'] / 100.0));
+        return round($discounted, wc_get_price_decimals());
+    }
+
+    // ============================================================
+    // Admin UI: variation editor
+    // ============================================================
+
+    public function render_variation_tier_editor($loop, $variation_data, $variation) {
+        $parent_id = (int) $variation->post_parent;
+        if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+            return;
+        }
+
+        $tiers = self::get_tiers($variation->ID);
+        ?>
+        <div class="studiou-fpp-qty-tiers-field form-row form-row-full" data-loop="<?php echo esc_attr($loop); ?>">
+            <label>
+                <?php esc_html_e('Quantity Discount Tiers', 'studiou-wc-free-photo-product'); ?>
+                <?php echo wc_help_tip(__('Define quantity thresholds and percentage discounts. The tier with the largest "From Qty" that is less than or equal to the cart quantity is applied to the entire line. Leave empty to use the explicit variation price.', 'studiou-wc-free-photo-product')); ?>
+            </label>
+            <table class="studiou-fpp-qty-tiers-table widefat">
+                <thead>
+                    <tr>
+                        <th class="studiou-fpp-qty-col-from"><?php esc_html_e('From Qty', 'studiou-wc-free-photo-product'); ?></th>
+                        <th class="studiou-fpp-qty-col-pct"><?php esc_html_e('Discount %', 'studiou-wc-free-photo-product'); ?></th>
+                        <th class="studiou-fpp-qty-col-remove"></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <?php foreach ($tiers as $idx => $tier) : ?>
+                        <tr class="studiou-fpp-qty-tier-row">
+                            <td>
+                                <input type="number" min="1" step="1"
+                                    name="studiou_fpp_qty_tiers[<?php echo esc_attr($loop); ?>][<?php echo esc_attr($idx); ?>][from_qty]"
+                                    value="<?php echo esc_attr($tier['from_qty']); ?>" />
+                            </td>
+                            <td>
+                                <input type="number" min="0" max="100" step="0.01"
+                                    name="studiou_fpp_qty_tiers[<?php echo esc_attr($loop); ?>][<?php echo esc_attr($idx); ?>][percent]"
+                                    value="<?php echo esc_attr($tier['percent']); ?>" />
+                            </td>
+                            <td>
+                                <button type="button" class="button studiou-fpp-qty-tier-remove"
+                                    title="<?php esc_attr_e('Remove row', 'studiou-wc-free-photo-product'); ?>">&times;</button>
+                            </td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+            <p>
+                <button type="button" class="button studiou-fpp-qty-tier-add" data-loop="<?php echo esc_attr($loop); ?>">
+                    <?php esc_html_e('+ Add tier', 'studiou-wc-free-photo-product'); ?>
+                </button>
+            </p>
+        </div>
+        <?php
+    }
+
+    public function save_variation_tiers($variation_id, $i) {
+        $parent_id = wp_get_post_parent_id($variation_id);
+        if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+            delete_post_meta($variation_id, self::META_KEY);
+            return;
+        }
+
+        $raw = array();
+        if (isset($_POST['studiou_fpp_qty_tiers'][$i]) && is_array($_POST['studiou_fpp_qty_tiers'][$i])) {
+            $raw = wp_unslash($_POST['studiou_fpp_qty_tiers'][$i]);
+        }
+
+        $sanitized = array();
+        foreach ($raw as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            $from = isset($row['from_qty']) ? absint($row['from_qty']) : 0;
+            if ($from < 1) {
+                continue;
+            }
+            $pct = isset($row['percent']) ? (float) $row['percent'] : 0.0;
+            $pct = max(0.0, min(100.0, $pct));
+            $sanitized[] = array('from_qty' => $from, 'percent' => $pct);
+        }
+
+        $sanitized = self::normalize_tiers($sanitized);
+
+        if (empty($sanitized)) {
+            delete_post_meta($variation_id, self::META_KEY);
+        } else {
+            update_post_meta($variation_id, self::META_KEY, $sanitized);
+        }
+    }
+
+    // ============================================================
+    // Frontend: expose tiers on variation data + placeholder container
+    // ============================================================
+
+    public function inject_variation_tier_data($variation_data, $product, $variation) {
+        $parent_id = $variation->get_parent_id();
+        if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+            return $variation_data;
+        }
+
+        $tiers = self::get_tiers($variation->get_id());
+        $base_price = get_post_meta($variation->get_id(), '_price', true);
+
+        $variation_data['studiou_fpp_qty_tiers'] = $tiers;
+        $variation_data['studiou_fpp_base_price'] = ($base_price !== '' && $base_price !== false) ? (float) $base_price : 0.0;
+
+        return $variation_data;
+    }
+
+    public function render_tier_display_placeholder() {
+        global $product;
+        if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
+            return;
+        }
+        echo '<div class="studiou-fpp-qty-tiers-display" style="display:none;"></div>';
+    }
+
+    /**
+     * Render a persistent notice above the variations form summarising tier availability.
+     * Only shown if at least one variation of the current product has tiers configured.
+     */
+    public function render_preform_notice() {
+        global $product;
+        if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
+            return;
+        }
+        if (!$product->is_type('variable')) {
+            return;
+        }
+
+        $has_tiers = false;
+        $max_discount = 0.0;
+        foreach ($product->get_children() as $variation_id) {
+            $tiers = self::get_tiers($variation_id);
+            if (!empty($tiers)) {
+                $has_tiers = true;
+                foreach ($tiers as $tier) {
+                    if ($tier['percent'] > $max_discount) {
+                        $max_discount = $tier['percent'];
+                    }
+                }
+            }
+        }
+
+        if (!$has_tiers) {
+            return;
+        }
+
+        $label = ($max_discount > 0)
+            ? sprintf(
+                /* translators: %s: maximum discount percentage */
+                __('Quantity discount available — save up to %s%%. Select a variant to see the full tier table.', 'studiou-wc-free-photo-product'),
+                rtrim(rtrim(number_format($max_discount, 2, '.', ''), '0'), '.')
+            )
+            : __('Quantity discount available — select a variant to see the full tier table.', 'studiou-wc-free-photo-product');
+        ?>
+        <div class="studiou-fpp-qty-tiers-notice">
+            <span class="dashicons dashicons-tag"></span>
+            <span class="studiou-fpp-qty-tiers-notice-text"><?php echo esc_html($label); ?></span>
+        </div>
+        <?php
+    }
+
+    /**
+     * Emit tier data for all variations of the current FPP product as an inline JSON
+     * object keyed by variation ID. Consumed by frontend JS to wire up custom variant
+     * table plugins (pvtfw) where the native variations form is absent.
+     */
+    public function emit_tier_data_script() {
+        if (!is_product()) {
+            return;
+        }
+        global $product;
+        if (!$product || !is_a($product, 'WC_Product')) {
+            $product = wc_get_product(get_queried_object_id());
+        }
+        if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
+            return;
+        }
+        if (!$product->is_type('variable')) {
+            return;
+        }
+
+        $data = array();
+        foreach ($product->get_children() as $variation_id) {
+            $tiers = self::get_tiers($variation_id);
+            if (empty($tiers)) {
+                continue;
+            }
+            $base_price = get_post_meta($variation_id, '_price', true);
+            $data[(string) $variation_id] = array(
+                'tiers'      => $tiers,
+                'base_price' => ($base_price !== '' && $base_price !== false) ? (float) $base_price : 0.0,
+            );
+        }
+
+        if (empty($data)) {
+            return;
+        }
+
+        echo '<script>window.studiouFppTierData = ' . wp_json_encode($data) . ';</script>';
+    }
+
+    // ============================================================
+    // Cart runtime
+    // ============================================================
+
+    public function apply_cart_discount($cart) {
+        if (is_admin() && !defined('DOING_AJAX')) {
+            return;
+        }
+        if (!$cart || !is_a($cart, 'WC_Cart')) {
+            return;
+        }
+
+        foreach ($cart->get_cart() as $cart_item) {
+            if (empty($cart_item['variation_id'])) {
+                continue;
+            }
+            $product = isset($cart_item['data']) ? $cart_item['data'] : null;
+            if (!$product) {
+                continue;
+            }
+
+            $parent_id = $product->get_parent_id();
+            if (!Studiou_WC_FPP_Product::is_fpp_product($parent_id)) {
+                continue;
+            }
+
+            $variation_id = (int) $cart_item['variation_id'];
+            $tiers = self::get_tiers($variation_id);
+            if (empty($tiers)) {
+                continue;
+            }
+
+            // Read base price from meta to avoid compounding if the hook fires twice per request
+            $base_price = get_post_meta($variation_id, '_price', true);
+            if ($base_price === '' || $base_price === false) {
+                continue;
+            }
+
+            $qty = (int) $cart_item['quantity'];
+            $discounted = self::compute_discounted_price((float) $base_price, $tiers, $qty);
+            if ($discounted > 0.0) {
+                $product->set_price($discounted);
+            }
+        }
+    }
+}

+ 24 - 0
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-product.php

@@ -32,6 +32,7 @@ class Studiou_WC_FPP_Product {
         $enabled = get_post_meta($product_id, '_studiou_fpp_enabled', true);
         $media_category = get_post_meta($product_id, '_studiou_fpp_media_category', true);
         $max_file_size = get_post_meta($product_id, '_studiou_fpp_max_file_size', true);
+        $max_uploads = get_post_meta($product_id, '_studiou_fpp_max_uploads', true);
         $upload_info_text = get_post_meta($product_id, '_studiou_fpp_upload_info_text', true);
         $min_width = get_post_meta($product_id, '_studiou_fpp_min_width', true);
         $min_height = get_post_meta($product_id, '_studiou_fpp_min_height', true);
@@ -102,6 +103,18 @@ class Studiou_WC_FPP_Product {
                     ),
                     'value'             => $max_file_size,
                 ));
+                woocommerce_wp_text_input(array(
+                    'id'                => '_studiou_fpp_max_uploads',
+                    'label'             => __('Max Uploads', 'studiou-wc-free-photo-product'),
+                    'description'       => __('Maximum number of photos a customer can upload for this product in a single visit. Leave empty or 0 for unlimited.', 'studiou-wc-free-photo-product'),
+                    'desc_tip'          => true,
+                    'type'              => 'number',
+                    'custom_attributes' => array(
+                        'step' => '1',
+                        'min'  => '0',
+                    ),
+                    'value'             => $max_uploads,
+                ));
                 ?>
             </div>
 
@@ -165,6 +178,9 @@ class Studiou_WC_FPP_Product {
             update_post_meta($product_id, '_studiou_fpp_max_file_size', $max_size);
         }
 
+        $max_uploads = isset($_POST['_studiou_fpp_max_uploads']) ? absint($_POST['_studiou_fpp_max_uploads']) : 0;
+        update_post_meta($product_id, '_studiou_fpp_max_uploads', $max_uploads);
+
         if (isset($_POST['_studiou_fpp_upload_info_text'])) {
             update_post_meta($product_id, '_studiou_fpp_upload_info_text', sanitize_textarea_field($_POST['_studiou_fpp_upload_info_text']));
         }
@@ -212,6 +228,14 @@ class Studiou_WC_FPP_Product {
         return !empty($size) ? (int) $size : 50;
     }
 
+    /**
+     * Max simultaneous uploads per batch for this product. 0 = unlimited.
+     */
+    public static function get_product_max_uploads($product_id) {
+        $val = get_post_meta($product_id, '_studiou_fpp_max_uploads', true);
+        return (int) $val;
+    }
+
     public static function get_product_upload_info_text($product_id) {
         return get_post_meta($product_id, '_studiou_fpp_upload_info_text', true);
     }

+ 222 - 70
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-single-product.php

@@ -5,21 +5,85 @@ if (!defined('WPINC')) {
 
 class Studiou_WC_FPP_Single_Product {
 
-    public function __construct() {
-        // Render upload area below the variation table on single product page
+    /** @var Studiou_WC_FPP_DB */
+    private $db;
+
+    public function __construct($db) {
+        $this->db = $db;
+
+        // Render upload cards UI at priority 35 on single product page.
+        // Also suppress pvtfw's variant table + available-options button on FPP products.
+        add_action('wp', array($this, 'maybe_suppress_pvtfw'), 5);
+        // Read-only price overview rendered right above the dropzone (1.5.12+).
+        add_action('woocommerce_single_product_summary', array($this, 'render_variant_price_table'), 34);
         add_action('woocommerce_single_product_summary', array($this, 'render_upload_area'), 35);
 
+        // Add a body class on FPP product pages for CSS scoping
+        add_filter('body_class', array($this, 'body_class'));
+
         // Server-side validation: block add-to-cart without photo (priority 1 = very early)
         add_filter('woocommerce_add_to_cart_validation', array($this, 'validate_add_to_cart'), 1, 6);
 
         // Safety net: if item was added without photo, remove it immediately
         add_action('woocommerce_add_to_cart', array($this, 'check_after_add_to_cart'), 10, 6);
+    }
+
+    /**
+     * Detach pvtfw's variant-table + available-options-button hooks on FPP products.
+     *
+     * pvtfw assigns its singletons to `$pvtfw_print_table` / `$pvtfw_available_btn` at the
+     * top of its class files, but the `require_once` call lives inside pvtfw's
+     * PVTFW_TABLE::includes() method — so those assignments end up as method-locals, NOT
+     * true globals. We therefore reach the singletons via their static ::instance()
+     * (the same object pvtfw registered the actions with). The variant-table hook and
+     * priority are derived from pvtfw's own pvtfw_variant_table_place option (default
+     * woocommerce_after_single_product_summary_9) so admin-configured placements
+     * remain supported.
+     */
+    public function maybe_suppress_pvtfw() {
+        if (!function_exists('is_product') || !is_product()) {
+            return;
+        }
+        $product_id = get_queried_object_id();
+        if (!$product_id || !Studiou_WC_FPP_Product::is_fpp_product($product_id)) {
+            return;
+        }
+
+        if (class_exists('PVTFW_PRINT_TABLE')) {
+            $print_table = PVTFW_PRINT_TABLE::instance();
+            $place = get_option('pvtfw_variant_table_place', 'woocommerce_after_single_product_summary_9');
+            $tail  = strrchr((string) $place, '_');
+            if ($tail !== false && strlen($tail) > 1) {
+                $priority = (int) ltrim($tail, '_');
+                $hook     = substr($place, 0, -strlen($tail));
+                if ($hook) {
+                    remove_action($hook, array($print_table, 'print_table'), $priority);
+                }
+            }
+        } elseif (defined('WP_DEBUG') && WP_DEBUG) {
+            error_log('STUDIOU FPP: PVTFW_PRINT_TABLE class not found; cannot detach pvtfw variant table.');
+        }
+
+        // Note: pvtfw's class name has a typo ("AVAILABE" missing the L) — match it verbatim
+        if (class_exists('PVTFW_AVAILABE_BTN')) {
+            $available_btn = PVTFW_AVAILABE_BTN::instance();
+            remove_action('woocommerce_single_product_summary', array($available_btn, 'available_options_btn'), 11);
+        }
+
+        // Hide WC's native price-range element (e.g. "10,00 Kč – 40,00 Kč") on FPP products.
+        // The variant price table we render at priority 34 already communicates per-variant
+        // pricing, and the range is visually noisy above it.
+        remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_price', 10);
+    }
 
-        // AJAX: store uploaded file reference in WC session
-        add_action('wp_ajax_studiou_wcfpp_set_upload_session', array($this, 'ajax_set_upload_session'));
-        add_action('wp_ajax_nopriv_studiou_wcfpp_set_upload_session', array($this, 'ajax_set_upload_session'));
-        add_action('wp_ajax_studiou_wcfpp_clear_upload_session', array($this, 'ajax_clear_upload_session'));
-        add_action('wp_ajax_nopriv_studiou_wcfpp_clear_upload_session', array($this, 'ajax_clear_upload_session'));
+    public function body_class($classes) {
+        if (function_exists('is_product') && is_product()) {
+            $product_id = get_queried_object_id();
+            if ($product_id && Studiou_WC_FPP_Product::is_fpp_product($product_id)) {
+                $classes[] = 'studiou-fpp-product-page';
+            }
+        }
+        return $classes;
     }
 
     public function render_upload_area() {
@@ -31,29 +95,112 @@ class Studiou_WC_FPP_Single_Product {
         wp_enqueue_style('studiou-wcfpp-frontend');
         wp_enqueue_script('studiou-wcfpp-frontend');
 
-        $product_id = $product->get_id();
+        $product_id    = $product->get_id();
         $max_file_size = Studiou_WC_FPP_Product::get_product_max_file_size($product_id);
+        $max_uploads   = Studiou_WC_FPP_Product::get_product_max_uploads($product_id);
+
+        // Variant list (id, label, base_price, tiers) — injected into the files-cards JS
+        $variants = $this->build_variant_list($product);
 
-        // Pass existing session state to JS for restore on page load
-        $session_data = array();
-        if (WC()->session) {
-            $att_id = WC()->session->get('studiou_fpp_attachment_id');
-            if ($att_id) {
-                $thumb = wp_get_attachment_image_src($att_id, 'thumbnail');
-                $preview = wp_get_attachment_image_src($att_id, 'woocommerce_single');
-                $session_data = array(
-                    'attachment_id'  => $att_id,
-                    'file_record_id' => WC()->session->get('studiou_fpp_file_record_id'),
-                    'file_name'      => WC()->session->get('studiou_fpp_file_name'),
-                    'thumbnail_url'  => $thumb ? $thumb[0] : '',
-                    'preview_url'    => $preview ? $preview[0] : ($thumb ? $thumb[0] : ''),
-                );
+        // Existing batch files (rehydrate the card stack across reloads)
+        $batch_files = self::resolve_batch_files($this->db, $product_id);
+        $batch_cards = array();
+        $hero_preview_url = '';
+        foreach ($batch_files as $row) {
+            $att_id  = (int) $row->attachment_id;
+            $thumb   = wp_get_attachment_image_src($att_id, 'thumbnail');
+            $preview = wp_get_attachment_image_src($att_id, 'woocommerce_single');
+            if ($hero_preview_url === '') {
+                $hero_preview_url = $preview ? $preview[0] : ($thumb ? $thumb[0] : '');
             }
+            $batch_cards[] = array(
+                'file_record_id' => (int) $row->id,
+                'attachment_id'  => $att_id,
+                'file_name'      => (string) $row->file_name,
+                'thumb_url'      => $thumb ? $thumb[0] : ($preview ? $preview[0] : ''),
+                'preview_url'    => $preview ? $preview[0] : ($thumb ? $thumb[0] : ''),
+                'variation_id'   => (int) $row->variation_id,
+            );
         }
 
-        wp_localize_script('studiou-wcfpp-frontend', 'studiouWcfppSession', $session_data);
+        wp_localize_script('studiou-wcfpp-frontend', 'studiouFppCardData', array(
+            'productId'      => $product_id,
+            'variants'       => $variants,
+            'maxUploads'     => $max_uploads,
+            'batchFiles'     => $batch_cards,
+            'heroPreviewUrl' => $hero_preview_url,
+        ));
+
+        // Keep studiouWcfppSession populated for legacy JS code paths that still read it
+        wp_localize_script('studiou-wcfpp-frontend', 'studiouWcfppSession', array());
 
         include STUDIOU_WCFPP_PLUGIN_DIR . 'views/single-product-upload.php';
+
+        if (is_product() && Studiou_WC_FPP_Product::is_fpp_product($product_id)) {
+            // Render the order-overview panel below the cards (see views/single-product-order-overview.php)
+            $cart = function_exists('WC') ? WC()->cart : null;
+            include STUDIOU_WCFPP_PLUGIN_DIR . 'views/single-product-order-overview.php';
+        }
+    }
+
+    /**
+     * Read-only variant price/discount table rendered above the dropzone (1.5.12+).
+     * Customers see all variant prices and the tiered quantity discounts before uploading.
+     * Purely informational — no radio/select/qty/add-to-cart.
+     */
+    public function render_variant_price_table() {
+        global $product;
+        if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
+            return;
+        }
+        $variants = $this->build_variant_list($product);
+        if (empty($variants)) {
+            return;
+        }
+        include STUDIOU_WCFPP_PLUGIN_DIR . 'views/single-product-price-table.php';
+    }
+
+    /**
+     * Produce the array of variation descriptors consumed by the upload-card JS.
+     * One entry per in-stock, purchasable variation: id, label, base_price, tiers.
+     */
+    private function build_variant_list($product) {
+        if (!$product || !$product->is_type('variable')) {
+            return array();
+        }
+        $variants = array();
+        foreach ($product->get_children() as $variation_id) {
+            $variation = wc_get_product($variation_id);
+            if (!$variation || !$variation->is_purchasable() || !$variation->variation_is_visible()) {
+                continue;
+            }
+            $base_price = get_post_meta($variation_id, '_price', true);
+            $label_parts = array();
+            foreach ($variation->get_attributes() as $attr_name => $attr_value) {
+                if ($attr_value === '') {
+                    continue;
+                }
+                $taxonomy = str_replace('attribute_', '', $attr_name);
+                $term     = taxonomy_exists($taxonomy) ? get_term_by('slug', $attr_value, $taxonomy) : null;
+                // Term names or non-tax values may carry HTML entities (&nbsp;, &#75;,
+                // &aacute;, …) pasted in by admins. <option> text is literal, so entities
+                // would render as-is. Decode before handing to JS.
+                $raw = $term ? $term->name : urldecode((string) $attr_value);
+                $label_parts[] = html_entity_decode($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+            }
+            $tiers = class_exists('Studiou_WC_FPP_Pricing')
+                ? Studiou_WC_FPP_Pricing::get_tiers($variation_id)
+                : array();
+
+            $variants[] = array(
+                'id'         => (int) $variation_id,
+                'label'      => implode(' / ', $label_parts),
+                'base_price' => ($base_price !== '' && $base_price !== false) ? (float) $base_price : 0.0,
+                'tiers'      => $tiers,
+                'in_stock'   => $variation->is_in_stock(),
+            );
+        }
+        return $variants;
     }
 
     /**
@@ -83,7 +230,11 @@ class Studiou_WC_FPP_Single_Product {
     }
 
     /**
-     * Validate before add-to-cart: block if FPP product and no photo in session.
+     * Validate before add-to-cart: block if FPP product and no photo reference resolvable.
+     *
+     * 1.5.0: the per-card Add-to-Cart path pre-stamps $cart_item_data['studiou_fpp_file_record_id']
+     * via our ajax_add_file_to_cart handler — accept that as the primary signal. Fall back to the
+     * legacy visitor-level resolver for the shortcode path.
      */
     public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id = 0, $variations = array(), $cart_item_data = array()) {
         $fpp_id = $this->resolve_fpp_product_id($product_id, $variation_id);
@@ -91,18 +242,61 @@ class Studiou_WC_FPP_Single_Product {
             return $passed;
         }
 
-        // Check WC session for uploaded file
-        if (WC()->session) {
-            $attachment_id = WC()->session->get('studiou_fpp_attachment_id');
-            if ($attachment_id) {
+        if (!empty($cart_item_data['studiou_fpp_file_record_id'])) {
+            $record = $this->db->get_file_record((int) $cart_item_data['studiou_fpp_file_record_id']);
+            if ($record && (int) $record->order_id === 0) {
                 return $passed;
             }
         }
 
+        if (self::resolve_uploaded_file($this->db)) {
+            return $passed;
+        }
+
         wc_add_notice(__('Please upload a photo before adding to cart.', 'studiou-wc-free-photo-product'), 'error');
         return false;
     }
 
+    /**
+     * Return all upload records belonging to the current visitor's batch
+     * that are not yet attached to an order, optionally scoped to a product.
+     *
+     * @return array<int,object>  list of DB rows, oldest first (ascending id)
+     */
+    public static function resolve_batch_files(Studiou_WC_FPP_DB $db, $product_id = 0) {
+        $token = Studiou_WC_FPP_Upload::get_batch_token();
+        if (empty($token)) {
+            return array();
+        }
+        return $db->get_batch_files($token, (int) $product_id);
+    }
+
+    /**
+     * Resolve the single "current" upload for callers that still think in single-file
+     * terms (shortcode fallback, the validate_add_to_cart fallback branch). Returns the
+     * oldest unbound file in the visitor's batch, or null if none.
+     *
+     * Since 1.5.3 this is just a thin wrapper on the batch transport — the legacy
+     * WC-session and legacy `studiou_fpp_att_id` / `studiou_fpp_rec_id` cookies are gone.
+     *
+     * @return array{attachment_id:int,file_record_id:int,file_name:string,thumb_url:string}|null
+     */
+    public static function resolve_uploaded_file(Studiou_WC_FPP_DB $db) {
+        $files = self::resolve_batch_files($db);
+        if (empty($files)) {
+            return null;
+        }
+        $first  = $files[0];
+        $att_id = (int) $first->attachment_id;
+        $thumb  = wp_get_attachment_image_src($att_id, 'thumbnail');
+        return array(
+            'attachment_id'  => $att_id,
+            'file_record_id' => (int) $first->id,
+            'file_name'      => (string) $first->file_name,
+            'thumb_url'      => $thumb ? $thumb[0] : (string) wp_get_attachment_url($att_id),
+        );
+    }
+
     /**
      * Safety net: if an FPP item was added without photo data, remove it.
      * This catches cases where validation was somehow bypassed.
@@ -120,46 +314,4 @@ class Studiou_WC_FPP_Single_Product {
         }
     }
 
-    public function ajax_set_upload_session() {
-        check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
-
-        $attachment_id = isset($_POST['attachment_id']) ? absint($_POST['attachment_id']) : 0;
-        $file_record_id = isset($_POST['file_record_id']) ? absint($_POST['file_record_id']) : 0;
-        $file_name = isset($_POST['file_name']) ? sanitize_text_field($_POST['file_name']) : '';
-
-        if (!$attachment_id || !$file_record_id) {
-            wp_send_json_error(array('message' => __('Invalid file data.', 'studiou-wc-free-photo-product')));
-            return;
-        }
-
-        if (WC()->session) {
-            WC()->session->set('studiou_fpp_attachment_id', $attachment_id);
-            WC()->session->set('studiou_fpp_file_record_id', $file_record_id);
-            WC()->session->set('studiou_fpp_file_name', $file_name);
-
-            // Store thumbnail URL directly so it can be used in cart without wp_get_attachment lookup
-            $thumb_url = '';
-            $thumb = wp_get_attachment_image_src($attachment_id, 'thumbnail');
-            if ($thumb) {
-                $thumb_url = $thumb[0];
-            } else {
-                $thumb_url = wp_get_attachment_url($attachment_id);
-            }
-            WC()->session->set('studiou_fpp_thumb_url', $thumb_url ?: '');
-        }
-
-        wp_send_json_success();
-    }
-
-    public function ajax_clear_upload_session() {
-        check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
-
-        if (WC()->session) {
-            WC()->session->set('studiou_fpp_attachment_id', null);
-            WC()->session->set('studiou_fpp_file_record_id', null);
-            WC()->session->set('studiou_fpp_file_name', null);
-        }
-
-        wp_send_json_success();
-    }
 }

+ 74 - 2
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-upload.php

@@ -82,6 +82,25 @@ class Studiou_WC_FPP_Upload {
             return;
         }
 
+        // Resolve/mint batch token — a stable per-visitor identifier that survives classic
+        // cookie <-> Store API Cart-Token session splits. Write it to the cookie once.
+        $batch_token = self::resolve_or_mint_batch_token();
+
+        // Enforce per-product max-uploads cap on the first chunk of a new file.
+        if ($chunk_index === 0) {
+            $max_uploads = Studiou_WC_FPP_Product::get_product_max_uploads($product_id);
+            if ($max_uploads > 0) {
+                $current_count = $this->db->count_batch_files($batch_token, $product_id);
+                if ($current_count >= $max_uploads) {
+                    wp_send_json_error(array('message' => sprintf(
+                        __('Maximum number of uploads reached (%d). Remove a photo before uploading another.', 'studiou-wc-free-photo-product'),
+                        $max_uploads
+                    )));
+                    return;
+                }
+            }
+        }
+
         $chunks_dir = $this->get_chunks_dir();
         if (!file_exists($chunks_dir)) {
             wp_mkdir_p($chunks_dir);
@@ -101,7 +120,7 @@ class Studiou_WC_FPP_Upload {
                 ob_end_clean();
             }
 
-            $result = $this->assemble_chunks($upload_id, $total_chunks, $file_name, $product_id);
+            $result = $this->assemble_chunks($upload_id, $total_chunks, $file_name, $product_id, $batch_token);
 
             // Clean again before sending JSON
             while (ob_get_level()) {
@@ -113,6 +132,10 @@ class Studiou_WC_FPP_Upload {
                 return;
             }
 
+            // Since 1.5.3 the batch_token cookie (set by resolve_or_mint_batch_token above
+            // and persisted in the DB row's batch_token column) is the sole transport —
+            // no more WC-session writes, no more legacy per-upload cookies.
+
             wp_send_json_success(array(
                 'complete'       => true,
                 'attachment_id'  => $result['attachment_id'],
@@ -130,7 +153,7 @@ class Studiou_WC_FPP_Upload {
         ));
     }
 
-    private function assemble_chunks($upload_id, $total_chunks, $file_name, $product_id) {
+    private function assemble_chunks($upload_id, $total_chunks, $file_name, $product_id, $batch_token = '') {
         $chunks_dir = $this->get_chunks_dir();
         $upload_dir = wp_upload_dir();
 
@@ -268,6 +291,7 @@ class Studiou_WC_FPP_Upload {
             'attachment_id' => $attachment_id,
             'customer_id'   => get_current_user_id(),
             'session_key'   => $session_key,
+            'batch_token'   => $batch_token,
             'file_name'     => $file_name,
         ));
 
@@ -388,4 +412,52 @@ class Studiou_WC_FPP_Upload {
 
         wp_send_json_success(array('message' => __('File removed.', 'studiou-wc-free-photo-product')));
     }
+
+    const COOKIE_BATCH = 'studiou_fpp_batch';
+
+    /**
+     * Return the current visitor's batch token, minting + persisting a new one if absent.
+     * Reads / writes the studiou_fpp_batch cookie.
+     */
+    public static function resolve_or_mint_batch_token() {
+        if (!empty($_COOKIE[self::COOKIE_BATCH])) {
+            $raw = preg_replace('/[^A-Za-z0-9]/', '', (string) $_COOKIE[self::COOKIE_BATCH]);
+            if (strlen($raw) >= 16 && strlen($raw) <= 64) {
+                return $raw;
+            }
+        }
+        $token = wp_generate_password(32, false);
+        $options = self::cookie_options(time() + DAY_IN_SECONDS);
+        @setcookie(self::COOKIE_BATCH, $token, $options);
+        $_COOKIE[self::COOKIE_BATCH] = $token;
+        return $token;
+    }
+
+    public static function get_batch_token() {
+        if (!empty($_COOKIE[self::COOKIE_BATCH])) {
+            $raw = preg_replace('/[^A-Za-z0-9]/', '', (string) $_COOKIE[self::COOKIE_BATCH]);
+            if (strlen($raw) >= 16 && strlen($raw) <= 64) {
+                return $raw;
+            }
+        }
+        return '';
+    }
+
+    public static function clear_batch_cookie() {
+        $options = self::cookie_options(time() - DAY_IN_SECONDS);
+        @setcookie(self::COOKIE_BATCH, '', $options);
+        unset($_COOKIE[self::COOKIE_BATCH]);
+    }
+
+    private static function cookie_options($expires) {
+        $path = defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/';
+        return array(
+            'expires'  => (int) $expires,
+            'path'     => $path,
+            'domain'   => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '',
+            'secure'   => is_ssl(),
+            'httponly' => true,
+            'samesite' => 'Lax',
+        );
+    }
 }

BIN
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.mo


+ 138 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.po

@@ -3,7 +3,7 @@
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.2.2\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.5.14\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: 2026-04-02 00:00+0000\n"
@@ -19,6 +19,9 @@ msgstr ""
 msgid "QDR - Studiou WC Free Photo Product requires WooCommerce to be installed and activated."
 msgstr "QDR - Studiou WC Free Photo Product vyžaduje nainstalovaný a aktivovaný WooCommerce."
 
+msgid "QDR - Studiou WC Free Photo Product requires the \"Product Variant Table for WooCommerce\" plugin to be installed and activated."
+msgstr "QDR - Studiou WC Free Photo Product vyžaduje nainstalovaný a aktivovaný plugin „Product Variant Table for WooCommerce“."
+
 msgid "Free Photo Files"
 msgstr "Soubory fotografií"
 
@@ -82,6 +85,27 @@ msgstr "Přetáhněte soubor sem nebo klikněte pro výběr"
 msgid "Remove"
 msgstr "Odebrat"
 
+msgid "Close"
+msgstr "Zavřít"
+
+msgid "Enlarge"
+msgstr "Zvětšit"
+
+msgid "Price list"
+msgstr "Ceník"
+
+msgid "Variant"
+msgstr "Varianta"
+
+msgid "Unit price"
+msgstr "Cena za kus"
+
+msgid "Quantity discount"
+msgstr "Množstevní sleva"
+
+msgid "Total"
+msgstr "Součet"
+
 msgid "Media Categories"
 msgstr "Kategorie médií"
 
@@ -360,3 +384,116 @@ msgstr "Bezpečnostní kontrola selhala."
 
 msgid "Add New Category"
 msgstr "Přidat novou kategorii"
+
+msgid "Quantity Discount Tiers"
+msgstr "Množstevní slevy"
+
+msgid "Define quantity thresholds and percentage discounts. The tier with the largest \"From Qty\" that is less than or equal to the cart quantity is applied to the entire line. Leave empty to use the explicit variation price."
+msgstr "Definujte množstevní prahy a procentuální slevy. Uplatní se úroveň s největší hodnotou „Od množství“, která je menší nebo rovna počtu kusů v košíku, a to na celou položku. Ponechte prázdné pro použití výchozí ceny varianty."
+
+msgid "From Qty"
+msgstr "Od množství"
+
+msgid "Discount %"
+msgstr "Sleva %"
+
+msgid "Remove row"
+msgstr "Odstranit řádek"
+
+msgid "+ Add tier"
+msgstr "+ Přidat úroveň"
+
+msgid "Quantity Discount"
+msgstr "Množstevní sleva"
+
+msgid "Discount"
+msgstr "Sleva"
+
+msgid "Unit Price"
+msgstr "Cena za kus"
+
+msgid "Quantity discount available — save up to %s%%. Select a variant to see the full tier table."
+msgstr "Je dostupná množstevní sleva — ušetříte až %s %%. Vyberte variantu pro zobrazení celé tabulky."
+
+msgid "Quantity discount available — select a variant to see the full tier table."
+msgstr "Je dostupná množstevní sleva — vyberte variantu pro zobrazení celé tabulky."
+
+msgid "Order #"
+msgstr "Číslo objednávky"
+
+msgid "Download as CSV"
+msgstr "Stáhnout jako CSV"
+
+msgid "Generating CSV file..."
+msgstr "Generování CSV souboru..."
+
+msgid "Could not create CSV file."
+msgstr "Nepodařilo se vytvořit CSV soubor."
+
+msgid "ID"
+msgstr "ID"
+
+msgid "Variation ID"
+msgstr "ID varianty"
+
+msgid "File URL"
+msgstr "URL souboru"
+
+# --- 1.5.0 strings ---
+
+msgid "Max Uploads"
+msgstr "Maximální počet nahrání"
+
+msgid "Maximum number of photos a customer can upload for this product in a single visit. Leave empty or 0 for unlimited."
+msgstr "Maximální počet fotografií, které může zákazník pro tento produkt nahrát během jedné návštěvy. Nechte prázdné nebo 0 pro neomezený počet."
+
+msgid "Upload Your Photos"
+msgstr "Nahrajte své fotografie"
+
+msgid "Upload up to %2$d photos (max %1$d MB each). Accepted formats: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP."
+msgstr "Nahrajte až %2$d fotografií (max. %1$d MB každá). Podporované formáty: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP."
+
+msgid "Maximum file size: %d MB. Accepted formats: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP."
+msgstr "Maximální velikost souboru: %d MB. Podporované formáty: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP."
+
+msgid "Drag & drop photos here, or click to choose files"
+msgstr "Přetáhněte fotografie sem, nebo klikněte pro výběr"
+
+msgid "Your order"
+msgstr "Vaše objednávka"
+
+msgid "Qty %d"
+msgstr "Ks %d"
+
+msgid "Subtotal:"
+msgstr "Mezisoučet:"
+
+msgid "Go to checkout"
+msgstr "Přejít k pokladně"
+
+msgid "Maximum number of uploads reached (%d). Remove a photo before uploading another."
+msgstr "Byl dosažen maximální počet nahrání (%d). Před nahráním další fotografie nějakou odstraňte."
+
+msgid "Invalid request."
+msgstr "Neplatný požadavek."
+
+msgid "Invalid variant."
+msgstr "Neplatná varianta."
+
+msgid "Cart unavailable."
+msgstr "Košík není dostupný."
+
+msgid "Cart line not found."
+msgstr "Položka košíku nenalezena."
+
+msgid "Could not add to cart."
+msgstr "Nelze přidat do košíku."
+
+msgid "Add to cart"
+msgstr "Do košíku"
+
+msgid "Maximum number of uploads reached (%d)."
+msgstr "Byl dosažen maximální počet nahrání (%d)."
+
+msgid "out of stock"
+msgstr "není skladem"

+ 187 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product.pot

@@ -2,7 +2,7 @@
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.2.2\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.5.14\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -18,6 +18,10 @@ msgstr ""
 msgid "QDR - Studiou WC Free Photo Product requires WooCommerce to be installed and activated."
 msgstr ""
 
+#: studiou-wc-free-photo-product.php
+msgid "QDR - Studiou WC Free Photo Product requires the \"Product Variant Table for WooCommerce\" plugin to be installed and activated."
+msgstr ""
+
 #: studiou-wc-free-photo-product.php
 msgid "Free Photo Files"
 msgstr ""
@@ -102,6 +106,34 @@ msgstr ""
 msgid "Remove"
 msgstr ""
 
+#: studiou-wc-free-photo-product.php
+msgid "Close"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php views/single-product-order-overview.php
+msgid "Enlarge"
+msgstr ""
+
+#: views/single-product-price-table.php
+msgid "Price list"
+msgstr ""
+
+#: views/single-product-price-table.php
+msgid "Variant"
+msgstr ""
+
+#: views/single-product-price-table.php
+msgid "Unit price"
+msgstr ""
+
+#: views/single-product-price-table.php
+msgid "Quantity discount"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Total"
+msgstr ""
+
 #: includes/class-studiou-wc-fpp-db.php
 msgid "Media Categories"
 msgstr ""
@@ -472,3 +504,157 @@ msgstr ""
 #: includes/class-studiou-wc-fpp-admin.php
 msgid "Security check failed."
 msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Quantity Discount Tiers"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Define quantity thresholds and percentage discounts. The tier with the largest \"From Qty\" that is less than or equal to the cart quantity is applied to the entire line. Leave empty to use the explicit variation price."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "From Qty"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Discount %"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Remove row"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "+ Add tier"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Quantity Discount"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Discount"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Unit Price"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Quantity discount available — save up to %s%%. Select a variant to see the full tier table."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-pricing.php
+msgid "Quantity discount available — select a variant to see the full tier table."
+msgstr ""
+
+#: views/admin-summary.php
+msgid "Order #"
+msgstr ""
+
+#: views/admin-summary.php
+msgid "Download as CSV"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Generating CSV file..."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "Could not create CSV file."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "ID"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "Variation ID"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-admin.php
+msgid "File URL"
+msgstr ""
+
+# --- 1.5.0 strings ---
+
+#: includes/class-studiou-wc-fpp-product.php
+msgid "Max Uploads"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-product.php
+msgid "Maximum number of photos a customer can upload for this product in a single visit. Leave empty or 0 for unlimited."
+msgstr ""
+
+#: views/single-product-upload.php
+msgid "Upload Your Photos"
+msgstr ""
+
+#: views/single-product-upload.php
+msgid "Upload up to %2$d photos (max %1$d MB each). Accepted formats: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP."
+msgstr ""
+
+#: views/single-product-upload.php
+msgid "Maximum file size: %d MB. Accepted formats: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP."
+msgstr ""
+
+#: views/single-product-upload.php
+msgid "Drag & drop photos here, or click to choose files"
+msgstr ""
+
+#: views/single-product-order-overview.php
+msgid "Your order"
+msgstr ""
+
+#: views/single-product-order-overview.php
+msgid "Qty %d"
+msgstr ""
+
+#: views/single-product-order-overview.php
+msgid "Remove"
+msgstr ""
+
+#: views/single-product-order-overview.php
+msgid "Subtotal:"
+msgstr ""
+
+#: views/single-product-order-overview.php
+msgid "Go to checkout"
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-upload.php
+msgid "Maximum number of uploads reached (%d). Remove a photo before uploading another."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-cart.php
+msgid "Invalid request."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-cart.php
+msgid "Invalid variant."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-cart.php
+msgid "Cart unavailable."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-cart.php
+msgid "Cart line not found."
+msgstr ""
+
+#: includes/class-studiou-wc-fpp-cart.php
+msgid "Could not add to cart."
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Add to cart"
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "Maximum number of uploads reached (%d)."
+msgstr ""
+
+#: studiou-wc-free-photo-product.php
+msgid "out of stock"
+msgstr ""

+ 351 - 0
studiou-wc-free-photo-product/multiupload-photo-instruction.md

@@ -0,0 +1,351 @@
+# Multi-upload Photo Product — Implementation Analysis
+
+Target plugin: `studiou-wc-free-photo-product`, baseline **v1.3.6**
+Target version: **1.4.0**
+Author: Dalibor Votruba (design doc) — 2026-04-16
+
+---
+
+## 1. Executive summary
+
+Today the plugin lets a shopper upload **one** photo per product-detail visit and then reuse it across any number of variants via the Product Variant Table for WooCommerce (pvtfw) rendering.
+
+The new model flips the axis: shoppers upload **many** photos, and the product-detail page shows the pvtfw variant table **replaced by a vertical stack of cards**, one card per uploaded photo. Each card is self-contained: thumbnail + filename + variant selector + quantity + live price with discount flag + Add-to-Cart button + Remove-upload button. Clicking Add to Cart on any card creates a **new order item** for that (upload × variant × qty) combination. Below the cards, a live order-overview panel summarises what the shopper has added for this product. The first uploaded image becomes the product's hero thumbnail on this page.
+
+This is a fundamental inversion of rendering axis, cart identity, and session transport — so the analysis below is worth settling before writing code.
+
+---
+
+## 2. Current architecture (baseline)
+
+| Concern | v1.3.6 behavior |
+|---|---|
+| Upload | Chunked ajax (`studiou_wcfpp_upload_chunk`), one file at a time, the *latest* upload replaces the previous one. |
+| Identity | One `file_record_id` in `wp_studiou_fpp_files`; transport via WC session **and** `studiou_fpp_att_id` / `studiou_fpp_rec_id` cookie pair (1.3.5 fix). |
+| Product page | pvtfw renders `<table class="variant">`, one row per variation. Our JS wires tier-discount display under each row. |
+| Add to cart | pvtfw's own button; validator (`woocommerce_add_to_cart_validation` priority 1) blocks if no uploaded photo is resolvable; `woocommerce_add_cart_item_data` stamps the single upload's IDs on the cart item. |
+| Hero image | JS `updateProductGallery()` replaces the first `.woocommerce-product-gallery__image img` with the upload preview. |
+| Tiers | Emitted as `window.studiouFppTierData`, applied on pvtfw rows client-side, authoritative server-side via `woocommerce_before_calculate_totals` priority 20. |
+
+**Single-file invariant is baked into:** cookie schema (two ints), resolver (`resolve_uploaded_file`), cart-item-data filter (reads session/cookie, not from the click), gallery swap, upload UI (one dropzone + one preview slot).
+
+---
+
+## 3. Proposed architecture
+
+### 3.1 Shift in identity — batch of uploads
+
+Per-visitor state becomes a **batch** of uploaded files, not a single upload.
+
+Introduce a **batch token** — a single opaque random string (`wp_generate_password(32, false)`) that identifies everything this visitor has uploaded in the current shopping session.
+
+- Written to **one** HttpOnly first-party cookie `studiou_fpp_batch` on the first chunk-upload completion, path `/`, same-site Lax, ~1 day expiry.
+- Reused by every subsequent upload from the same browser (not rotated until all-batch remove, or checkout).
+- Persisted as a new column `batch_token VARCHAR(32)` on `wp_studiou_fpp_files`, indexed, filled on row insert.
+- Survives the Store API / Cart-Token split for the same reason the 1.3.5 cookies did: cookies travel with any same-origin request, JWTs don't.
+
+The resolver changes shape: instead of returning one record, it returns the **list** of file records belonging to the current batch that are not yet bound to an order (`order_id = 0`).
+
+```php
+Studiou_WC_FPP_Single_Product::resolve_batch_files(
+    Studiou_WC_FPP_DB $db
+): array  // list of records, empty if none
+```
+
+The existing single-file `resolve_uploaded_file()` stays as a thin wrapper returning the *first* unbound record, for any legacy path still using it (shortcode — see §6).
+
+### 3.2 DB schema change
+
+**`wp_studiou_fpp_files`** — add one column, one index, bump db version:
+
+```sql
+ALTER TABLE wp_studiou_fpp_files
+  ADD COLUMN batch_token VARCHAR(32) NOT NULL DEFAULT '' AFTER session_key,
+  ADD KEY batch_token (batch_token);
+```
+
+Applied via `dbDelta` on plugin upgrade (same machinery as `create_tables()`). Existing rows get empty `batch_token`; they're harmless — they're either already ordered or orphaned.
+
+No change to `_studiou_fpp_attachment_id` / `_studiou_fpp_file_record_id` / `_studiou_fpp_file_name` / `_studiou_fpp_thumb_url` order-item meta — each cart line still carries exactly one file reference, there are just **more cart lines** now.
+
+### 3.3 Upload flow (multi-file)
+
+**Server-side** — `Studiou_WC_FPP_Upload::handle_chunk_upload` barely changes:
+
+- Continue to process one file per request (chunked).
+- On final-chunk assembly, determine batch token: read `studiou_fpp_batch` cookie; if missing/stale, mint a new one and set the cookie. Write the token into the new row's `batch_token`.
+- Enforce the per-product **max-uploads cap** (see §3.9): before accepting the first chunk of a new file, count current batch rows for this product; reject with an error if the cap is hit.
+- Drop the legacy two-int cookie writes (kept only as read-fallback for one release — see §6).
+
+**Client-side** — reworked `assets/js/frontend.js`:
+
+- Replace the single-preview UI with the **upload cards region** (§3.4).
+- `<input type="file" multiple>` on the dropzone; drop events support multiple files.
+- On select/drop with N files, enqueue them and upload sequentially (parallel upload is a phase-2 tweak — sequential keeps chunk semantics simple and avoids server load spikes).
+- While a file is uploading, its card shows a progress bar and disables the variant/qty/add-to-cart controls.
+- On successful upload: swap the card into "ready" state with thumbnail, filename, variant dropdown, qty, Add-to-Cart button, and a `×` Remove-upload button.
+- On remove-upload click: call `studiou_wcfpp_remove_upload` (per-record removal already exists in the current API), then drop the card from the DOM.
+
+### 3.4 Upload cards (replaces pvtfw table on FPP products)
+
+**This is where the UX pivots.** Instead of a row-per-variation table, the page shows a **single vertical stack of cards**, one card per uploaded file. Each card combines the roles of "upload queue item" and "ordering row" — there is no separate queue region.
+
+#### 3.4.1 pvtfw suppression — resolved
+
+pvtfw registers its table via (from `inc/frontend/class_pvtfw_print_table.php:367`):
+
+```php
+$place = get_option('pvtfw_variant_table_place', 'woocommerce_after_single_product_summary_9');
+// parses trailing "_N" as priority, the rest as hook name
+add_action($hook, array($pvtfw_print_table, 'print_table'), $priority);
+```
+
+The instance is a global `$pvtfw_print_table = PVTFW_PRINT_TABLE::instance()`. We can cleanly detach it on `wp` when the current product is FPP-enabled:
+
+```php
+add_action('wp', function () {
+    if (!function_exists('is_product') || !is_product()) return;
+    $product_id = get_queried_object_id();
+    if (!Studiou_WC_FPP_Product::is_fpp_product($product_id)) return;
+
+    global $pvtfw_print_table;
+    if (!$pvtfw_print_table) return;
+
+    // Mirror pvtfw's option parsing (default: woocommerce_after_single_product_summary_9)
+    $place    = get_option('pvtfw_variant_table_place', 'woocommerce_after_single_product_summary_9');
+    $tail     = strrchr($place, '_');
+    $priority = (int) ltrim($tail, '_');
+    $hook     = substr($place, 0, -strlen($tail));
+
+    remove_action($hook, array($pvtfw_print_table, 'print_table'), $priority);
+
+    // Also suppress the "available options" button (attached at priority 11)
+    global $pvtfw_available_btn;
+    if ($pvtfw_available_btn) {
+        remove_action('woocommerce_single_product_summary', array($pvtfw_available_btn, 'available_options_btn'), 11);
+    }
+});
+```
+
+No CSS fallback needed — the hook names are stable API pvtfw uses to configure its own placement, and if a future pvtfw version renames them, we'd want to know (prominent visual glitch beats silent skew). A diagnostic `if (WP_DEBUG) error_log(...)` when the global is unexpectedly missing is enough.
+
+pvtfw's `template_redirect` hook at priority 29 (`remove_add_to_cart`) keeps the native WC Add to Cart form off the page — we **leave that in place**; the card-per-upload UI is our sole Add-to-Cart surface on FPP products.
+
+#### 3.4.2 Card layout
+
+One card per upload; cards stacked vertically in a single column. This is the layout at all breakpoints — mobile gets the same shape, with tighter spacing. "Stacked cards below 640 px" is really a no-op because the cards are already stacked; the mobile pass just tightens gutters and may wrap the variant/qty/button row.
+
+Card markup (target):
+
+```
+┌───────────────────────────────────────────────────────────┐
+│ ┌──────┐  filename.jpg                               [× ] │
+│ │      │                                                  │
+│ │ thumb│  [ Variant ▾ ]   [ − ] [ qty ] [ + ]             │
+│ │      │                                                  │
+│ └──────┘  20 Kč  ·  ⚑ −10%              [ Add to Cart ]   │
+└───────────────────────────────────────────────────────────┘
+```
+
+- `[× ]` top-right = Remove-upload (calls `studiou_wcfpp_remove_upload`). Only this button removes from `wp_studiou_fpp_files` + media library; the order-overview remove is cart-only (see §3.6).
+- Thumbnail uses `wp_get_attachment_image_src($att_id, 'thumbnail')` server-side, or the URL returned by the upload AJAX for just-uploaded cards.
+- Variant dropdown is populated from `$product->get_children()` with embedded `data-variation-id`, `data-base-price`, and tier JSON (see §3.7) per option.
+- Qty input + `−`/`+` buttons — same visual language as pvtfw's `.qty-count` for continuity.
+- Price + discount flag — live, client-computed from the selected variant's base price × qty with the applicable tier; the ⚑ badge appears only when a tier is active. Authoritative price is still server-side at cart time.
+- Add-to-Cart button — disabled while the upload is still in flight; reads `[data-file-record-id]` from the card root and POSTs to `studiou_wcfpp_add_file_to_cart` (§3.5).
+
+On page reload, the card stack is rehydrated server-side from `resolve_batch_files($db)`, preserving queue state across refreshes (decision #2). The upload dropzone stays visible above the stack so more uploads can be added at any time.
+
+### 3.5 Per-card Add to Cart — new AJAX endpoint
+
+The Add-to-Cart flow is entirely ours now. New handler on `Studiou_WC_FPP_Cart`:
+
+```
+POST admin-ajax.php?action=studiou_wcfpp_add_file_to_cart
+Body: file_record_id, variation_id, quantity, nonce=studiou-wcfpp-front-nonce
+```
+
+Server flow:
+
+1. Verify nonce.
+2. Load the file record; reject if missing, bound to an order (`order_id != 0`), or if its `batch_token` doesn't match the caller's `studiou_fpp_batch` cookie.
+3. Load the variation; reject if its parent isn't an FPP product.
+4. Compose `$variation_attributes = $variation->get_variation_attributes()`.
+5. Pre-stamp `$cart_item_data['studiou_fpp_file_record_id'] = $file_record_id` so `add_cart_item_data` knows exactly which upload this line is for.
+6. Call `WC()->cart->add_to_cart($parent_id, $qty, $variation_id, $variation_attributes, $cart_item_data)`.
+7. Respond with `wp_send_json_success({ cart_key, overview_html, total_html })` — JS uses this to refresh the order-overview panel in-place, no reload.
+
+Cart-item uniqueness is automatic: WC's `generate_cart_id()` hashes `$cart_item_data`, and since every line carries a distinct `studiou_fpp_file_record_id`, each (upload × variant) pairing becomes its own cart line (a new **order item**, matching decision #3). Adding the same (upload, variant) pair twice correctly increments qty on the existing line.
+
+`woocommerce_add_to_cart_validation` stays but its meaning narrows: on the per-card flow it only needs to assert `!empty($cart_item_data['studiou_fpp_file_record_id'])` and re-verify batch ownership. The resolver fallback stays alive for the shortcode path.
+
+`woocommerce_add_cart_item_data` changes from "read the single-file cookie" to:
+
+```php
+if (!empty($cart_item_data['studiou_fpp_file_record_id'])) {
+    $record = $this->db->get_file_record((int) $cart_item_data['studiou_fpp_file_record_id']);
+    if ($record && $record->order_id == 0) {
+        // stamp attachment_id, file_name, thumb_url on cart_item_data from record
+        // update the record's variation_id (the most recently chosen one wins)
+        return $cart_item_data;
+    }
+}
+// legacy/shortcode fallback
+return $this->legacy_add_cart_item_data($cart_item_data, $product_id, $variation_id);
+```
+
+**Raw image stays single.** Per decision #3: the same `attachment_id` can be referenced by multiple cart lines and multiple order items. The media file (the raw image) exists exactly once in the media library — print source is single even if the shopper prints it in 3 sizes. This is already how the current code works; nothing new.
+
+### 3.6 Order-overview panel
+
+Rendered below the upload-cards stack on the same page. Template lives in `views/single-product-order-overview.php`. Scope: **only cart lines for the current product** (decision #4) — the shopper sees what they've composed for *this* photo product, not their global cart.
+
+Per-line display (read-only — decision #6):
+
+- Upload thumbnail (from cart item meta `_studiou_fpp_thumb_url`)
+- Variation label (`wc_get_formatted_variation($variation)` or `$variation->get_formatted_name()`)
+- Quantity — plain display, non-editable
+- Unit price + line total with `−X%` badge if a tier is active (the cart line already carries the discounted unit price because `Studiou_WC_FPP_Pricing::apply_cart_discount` has run on `woocommerce_before_calculate_totals` priority 20)
+- Remove link — **cart removal only** (decision #5). Clicking removes the line via our AJAX endpoint or WC's native `?remove_item=...&_wpnonce=...`; it does **not** delete the upload. The upload itself is removable only via the `×` button on its upload card (§3.4.2).
+
+Bottom of panel: subtotal for the filtered (current-product) lines + a "Go to checkout" button linking to `wc_get_checkout_url()`.
+
+**Rendering**: server-rendered initial state on page load; JS re-renders after each Add-to-Cart (using `overview_html` returned from `studiou_wcfpp_add_file_to_cart`). The panel listens to a custom DOM event (`studiou-fpp:cart-updated`) so remove handlers trigger a refresh.
+
+### 3.7 Tiers in the card layout
+
+Data model unchanged — tiers remain per-variation (`_studiou_fpp_qty_tiers`). What changes is the UI wiring:
+
+- `Studiou_WC_FPP_Pricing::emit_tier_data_script()` keeps emitting `window.studiouFppTierData` keyed by `variation_id`. No change.
+- `initVariantTableTiers()` goes away; a new `initUploadCards()` takes over. For each card, on `change` of the variant dropdown or `input/change/click` on the qty controls, recompute `discounted_unit = base_price * (1 − tier_percent/100)` and update the card's price + badge.
+- Server-side authority unchanged: `apply_cart_discount` on `woocommerce_before_calculate_totals` priority 20 is still the only voice that matters for the real cart total.
+
+### 3.8 First uploaded file as product thumbnail
+
+Simplest implementation = JS-only, a direct extension of the current `updateProductGallery()`.
+
+- On page load: server passes the **first** resolved batch file's `preview_url` to `studiouWcfppSession.hero_preview_url` via `wp_localize_script`.
+- JS runs `updateProductGallery(heroPreviewUrl)` if present — same selectors it uses today.
+- On each successful upload: if this is the first card in the stack, call `updateProductGallery()` with the new file's preview.
+- On remove-first-upload: if there is a remaining card, swap the gallery to the new first; otherwise call `restoreProductGallery()`.
+
+The server-side `woocommerce_product_get_image_id` filter is **not** used on the product-detail page itself — too risky, fires in many contexts (archive loops, related products, REST). JS stays scoped to the current page.
+
+### 3.9 Product-level max-uploads setting
+
+New post meta `_studiou_fpp_max_uploads` on the variable product, admin-edited in the existing Free Photo product tab alongside `_studiou_fpp_max_file_size`. Default `0` = unlimited (decision #1 — "conditionally restricted and defined on product").
+
+Enforcement:
+
+- Server: `handle_chunk_upload` counts current batch rows for this product before accepting chunk 0 of a new upload. Over the cap → `wp_send_json_error('Max uploads reached.')`.
+- Client: `initUploadCards` disables the dropzone and shows a hint "Max N photos reached" when the count == cap.
+
+---
+
+## 4. File-by-file impact
+
+| File | Kind | Change |
+|---|---|---|
+| `studiou-wc-free-photo-product.php` | PHP | Bump to `1.4.0` + `STUDIOU_WCFPP_VERSION`. Extend admin i18n block. |
+| `includes/class-studiou-wc-fpp-db.php` | PHP | Add `batch_token` column + index in `create_tables()`. Add `get_file_records_by_batch($token, $product_id=0)`. Bump `studiou_wcfpp_db_version`. |
+| `includes/class-studiou-wc-fpp-upload.php` | PHP | Replace single-file cookie pair with `studiou_fpp_batch`. Mint-or-reuse batch token on assembly; write it into the new row. Enforce `_studiou_fpp_max_uploads`. Remove cookie on full-batch remove (new endpoint). |
+| `includes/class-studiou-wc-fpp-single-product.php` | PHP | Replace "upload area" render with "dropzone + upload-cards stack + order-overview" composite. New `resolve_batch_files()` helper. Attach the pvtfw suppression `add_action('wp', …)` described in §3.4.1. |
+| `includes/class-studiou-wc-fpp-cart.php` | PHP | New `ajax_add_file_to_cart()` + `ajax_remove_cart_line()`. Rewrite `add_cart_item_data()` to prefer pre-stamped `file_record_id`; keep legacy resolver as fallback. |
+| `includes/class-studiou-wc-fpp-pricing.php` | PHP | Keep `emit_tier_data_script`; drop pvtfw-specific DOM wiring (moves to the new JS module). |
+| `includes/class-studiou-wc-fpp-product.php` | PHP | Add the "Max uploads" field in the admin tab (alongside max file size / resolution limits). Save/sanitize with the other meta. |
+| `views/single-product-upload.php` | PHP/HTML | **Rewrite.** Dropzone + upload-cards stack + order-overview container + `<template id="studiou-fpp-card-tpl">` for client-side clone. |
+| `views/single-product-order-overview.php` | PHP/HTML | **New.** Server-rendered initial state of the order overview. |
+| `assets/js/frontend.js` | JS | Substantial rewrite. New modules: `uploadCards()` (unified upload + order row), `orderOverview()`, `heroImage()`. Delete `initVariantTableTiers()`, `initQtyTiers()`. |
+| `assets/css/frontend.css` | CSS | Card styles (thumbnail, controls, buttons, hover/disabled states), order-overview layout, discount badge. |
+| `CLAUDE.md`, `readme.md` | docs | Describe new mental model, pvtfw suppression, hooks, endpoints. |
+| `languages/*.pot`, `*.po` | i18n | New strings: "Drop photos here", "Remove upload", "Add to cart", "Your order", "Go to checkout", "Max uploads reached (%d)", etc. |
+
+---
+
+## 5. Hook / endpoint inventory
+
+### New
+- `wp_ajax_studiou_wcfpp_add_file_to_cart` / nopriv — per-card Add to Cart
+- `wp_ajax_studiou_wcfpp_remove_cart_line` / nopriv — remove single cart line from the order overview (cart-only, does not touch upload)
+- `wp` (priority ~0) — conditional pvtfw suppression on FPP products (§3.4.1)
+
+### Changed
+- `woocommerce_add_to_cart_validation` (priority 1) — now checks `$cart_item_data['studiou_fpp_file_record_id']` first; fallback to resolver
+- `woocommerce_add_cart_item_data` — now reads pre-stamped `file_record_id` first; fallback to resolver
+
+### Retired (post-migration)
+- `studiou_wcfpp_set_upload_session` / `studiou_wcfpp_clear_upload_session` AJAX handlers — cookie-only now
+
+### Unchanged
+- Chunk upload internals (`studiou_wcfpp_upload_chunk`)
+- `woocommerce_before_calculate_totals` tier application
+- Cart/order thumbnail filters (`woocommerce_cart_item_thumbnail`, Store API image overrides)
+- Order-item linking (`woocommerce_checkout_order_processed`, `…store_api_checkout_order_processed`, `…thankyou`)
+
+---
+
+## 6. Backward compatibility & migration
+
+- **Shortcode (`[studiou_free_photo_product id="X"]`)** — lives on arbitrary pages, not the WC product-detail, so the pvtfw suppression doesn't touch it. Keep its current single-file UX as-is: single dropzone, single-file resolver fallback, WC native variations form. The new card-stack UX is specifically for the product-detail page.
+- **Legacy cookies** — `studiou_fpp_att_id` / `studiou_fpp_rec_id` kept as read-fallback in the resolver for one release so shoppers mid-session during the upgrade don't lose their upload. Removed in v1.4.1.
+- **DB rows without `batch_token`** — left as-is. Already-ordered or orphaned; new uploads go through the new path.
+- **Already-placed orders** — no migration needed. Order-item meta is per-line and has been multi-line capable since the beginning.
+- **Existing `_studiou_fpp_qty_tiers`** — untouched.
+
+---
+
+## 7. Decisions (resolved)
+
+| # | Decision | Resolution |
+|---|---|---|
+| 1 | Max uploads per visit | **Product-level setting** `_studiou_fpp_max_uploads` (default 0 = unlimited), edited in the Free Photo product tab next to Max file size. Enforced both client-side and in `handle_chunk_upload`. |
+| 2 | Upload queue persistence on reload | **Yes.** Server rehydrates the card stack from `resolve_batch_files($db)` on page load; batch cookie persists across reloads. |
+| 3 | Same file, multiple variants | **Yes — by explicit repeat add.** Each Add-to-Cart creates a new cart line / order item. Raw image remains single in the media library (one `attachment_id`, referenced by multiple order items). No "duplicate card" button — shopper adds the same card multiple times with different variant/qty selections. |
+| 4 | Order-overview scope | **Current product only.** Cart lines filtered by product_id = current product. |
+| 5 | Remove on overview | **Cart remove only.** Upload removal lives on the upload card's `×` button, not on the order-overview line. |
+| 6 | Inline qty edit on overview | **No.** Shopper removes the line and re-adds with a new qty from the upload card. |
+| 7 | Mobile layout | **Stacked cards below 640 px.** The card layout is already a vertical stack at all breakpoints; mobile pass only tightens internal gutters and may wrap variant/qty/button row. |
+| 8 | pvtfw suppression method | **Clean `remove_action()` on `wp` hook** — §3.4.1. Uses pvtfw's own `pvtfw_variant_table_place` option to discover the hook/priority; also removes the "available options" button. No CSS fallback needed (pvtfw's hook names are stable API). |
+
+---
+
+## 8. Phased rollout
+
+Shippable in one release, reviewable in phases:
+
+1. **DB + batch-token refactor.** Add `batch_token` column, new resolver returning list, replace cookie pair. Zero UI change — regression-test single-file flow.
+2. **Upload cards stack (UI).** Suppress pvtfw table on FPP products, render the card stack, wire multi-upload + drag-drop. Still uses legacy single-file add-to-cart path under the hood (one card, one add).
+3. **Per-card Add to Cart.** New AJAX endpoint, per-card wiring, removes the session-based cart transport.
+4. **Order overview + hero-image swap.** The two final UI pieces. Both additive over phase 3.
+5. **Docs + i18n.** Update `CLAUDE.md`, `readme.md`, `*.po`/`*.pot`, regenerate `.mo`.
+
+Recommended commits: one per phase, final one for docs + version bump. Target version: **1.4.0** — significant but additive (no public API removed yet; legacy cookies tolerated for one release).
+
+---
+
+## 9. Risks & mitigations
+
+| Risk | Mitigation |
+|---|---|
+| pvtfw internal hook names change in a future version → our `remove_action()` silently mis-targets, the table re-appears alongside our cards. | The hook/priority is derived from pvtfw's own option, so re-configurations flow through automatically. If pvtfw changes the option name itself, we'll notice (prominent visual glitch). A `WP_DEBUG` warning when `$pvtfw_print_table` is missing catches class renames. |
+| Batch cookie collision across shoppers sharing a browser profile / proxy. | 32-char random token → collision probability negligible. Cookies are per-browser. |
+| Upload-in-flight + Add-to-Cart race. | Card is rendered in disabled state during upload; Add-to-Cart button only enabled once the record row exists. |
+| Order-admin summary shows many rows per order now. | Already works — admin paginates per file record. Consider an order-grouping toggle in a future phase. |
+| Max-uploads bypass by skipping JS. | Enforced server-side in `handle_chunk_upload` before accepting chunk 0 of a new file. |
+| Discount-tier UI lag on fast qty changes. | Debounce recompute at 50 ms; server-side pricing remains authoritative. |
+| Block cart renders "same product" many times (each card produces its own cart line). | Same as today's multi-variant flow. Verified to work in 1.3.5. |
+
+---
+
+## 10. Out of scope
+
+- In-browser cropping / rotating / editing of uploaded photos.
+- Per-file variant overrides (e.g. "this file can only print 10×15").
+- Upload resume across sessions.
+- Saving upload batches for logged-in users to revisit later.
+
+If any of these move into scope, re-open this doc before coding.
+
+---
+
+*End of document.*

+ 109 - 21
studiou-wc-free-photo-product/readme.md

@@ -1,6 +1,41 @@
 # QDR - Studiou WC Free Photo Product
 
-WordPress (6.9.4+) / WooCommerce (10.6.2+) plugin that allows publishing special variable products with custom raw image upload for photo printing. Customers select a product variant using WooCommerce's native variation selector and upload their photo file, which gets stored in the WordPress media library under a configurable media category.
+WordPress (6.9.4+) / WooCommerce (10.6.2+) plugin that allows publishing special variable products with custom raw image upload for photo printing. Customers upload one or more photos on the product page and compose an order where each uploaded photo can be printed in a different variant at its own quantity. Uploaded files are stored in the WordPress media library under a configurable media category.
+
+## What's new in 1.5.14
+
+- **Cart "Estimated total" rename hardened against clobber.** The 1.5.13 override on `wp-i18n` ran *before* WC's `wc-cart-checkout-base-js-translations` inline bundle, which then merged its own `"Estimated total": ["Odhadovaný součet"]` locale data on top and restored the original text. Now re-applies the override as an `after`-inline script on `wc-cart-checkout-base` (and `wc-cart-block-frontend`), plus a DOMContentLoaded safety net, so our value wins regardless of script order.
+
+## What's new in 1.5.13
+
+- **Cart "Estimated total" rename now actually reaches the block cart.** The 1.5.12 `gettext_woocommerce` PHP filter only catches PHP-rendered strings, but the WC block cart is React-rendered and pulls its labels from a separate `wc-cart-checkout-base-js-translations` bundle via `wp.i18n.__()`. Added an inline script on the `wp-i18n` handle that calls `wp.i18n.setLocaleData({ 'Estimated total': […] }, 'woocommerce')` as soon as wp-i18n loads, overriding the bundled Czech translation "Odhadovaný součet" with our own. Translation value still lives in our .po so it's locale-aware. Also remapped "Cart totals" for consistency.
+
+## What's new in 1.5.12
+
+- **Read-only variant price table above the dropzone.** Customers see all variant prices plus the tiered quantity-discount schedule before they upload. Server-side table uses `wc_price()` so formatting follows the WC pipeline. Out-of-stock rows greyed out. Rendered on `woocommerce_single_product_summary` priority 34 (right before the upload area at priority 35).
+- **WC price-range hidden on FPP products.** The "10,00 Kč – 40,00 Kč" span that WC outputs by default is suppressed via `remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_price', 10)` — the new variant table already communicates per-variant pricing.
+- **Cart "Estimated total" → "Total" (cs_CZ: "Součet").** `gettext_woocommerce` filter reroutes WC's core string through our own textdomain so the translation sits in our .po and stays locale-aware.
+
+## What's new in 1.5.11
+
+- **Mobile upload fix.** Tapping the dropzone on mobile raised `Maximum call stack size exceeded` and prevented the file picker from opening. The `<input type="file">` lives inside the dropzone, so `$fileInput.trigger('click')` dispatched a bubbling jQuery event that re-entered the dropzone's own click handler in an infinite loop (mobile surfaces it because the browser synthesizes an extra click when the picker closes). Fix: open the picker via native `$fileInput[0].click()` (no bubbling jQuery event) and bail out of the dropzone handler when the click originates on the input itself.
+
+## What's new in 1.5.10
+
+- **Clean currency rendering in the variant combobox.** WooCommerce's `get_woocommerce_currency_symbol()` returns `Kč` as `&#75;&#269;` and the Czech `get_woocommerce_price_format()` as `%2$s&nbsp;%1$s`. Both values flowed into `formatPricePlain()`, whose result is assigned via jQuery `.text()`, so entities rendered verbatim as `10,00&nbsp;&#75;&#269;` in `<option>` labels. Decoded at the PHP localize step with `html_entity_decode(..., ENT_QUOTES | ENT_HTML5, 'UTF-8')` before handing to JS.
+
+## What's new in 1.5.9
+
+- **HTML entities in variant attribute labels.** Term names or non-taxonomy attribute values stored in the WP database sometimes carry entities (`&nbsp;`, `&#75;`, `&aacute;`, …) pasted in from rich editors. Since `<option>` text is literal, they leaked into the variant combobox as-is. `build_variant_list()` (JS combobox data) and the order-overview template now decode entities (and urldecode non-taxonomy values) before building the label.
+
+## What's new in 1.5.8
+
+- **Explicit "Enlarge" button** on every upload card (in the price-row next to Add-to-Cart) and every order-overview line. Opens the lightbox with the `woocommerce_single`-size preview so customers can check image detail before committing. The button sits clear of the × remove button and is wired via direct `element.onclick` + jQuery delegation to survive any theme-level click interceptors. If the overlay DOM fails to initialize for any reason, the button falls back to opening the preview in a new browser tab instead of being a no-op. Thumbnail clicks remain as a secondary opener.
+- **Cart-page thumbnail fix (1.5.4+).** Multiple cart lines that share a `variation_id` now each render their OWN uploaded photo in the block cart. Previously the `woocommerce_product_get_image_id` filter fired per-product and could only return the first matching line's image, so lines sharing a variation all showed the same photo. The new `woocommerce_get_cart_item_from_session` approach clones the cart line's product instance and stamps `set_image_id()` per line, keeping the block cart and classic cart in sync with the admin order view.
+
+## What's new in 1.5.0
+
+Multi-upload overhaul. The product-detail page now shows a **vertical stack of cards** — one per uploaded photo — in place of the pvtfw variant table on FPP products. Each card carries its own variant selector, quantity stepper, live tier-discounted price (with `−X%` badge), and its own Add-to-Cart button. An **order-overview panel** below the cards lists the lines composed for this product, with per-line remove and subtotal. The first uploaded image becomes the product hero thumbnail on that page. A per-product **Max Uploads** setting caps how many photos a customer can queue in one visit (default 0 = unlimited).
 
 ## Requirements
 
@@ -8,6 +43,7 @@ WordPress (6.9.4+) / WooCommerce (10.6.2+) plugin that allows publishing special
 - WooCommerce 10.0+
 - PHP 8.2+
 - PHP ZipArchive extension (for admin ZIP download)
+- **Product Variant Table for WooCommerce** plugin (folder slug: `product-variant-table-for-woocommerce`) — still required as a dependency for non-FPP variable products across the shop. On FPP products the plugin **suppresses pvtfw's rendering** (via `remove_action()` on the hook/priority it reads from pvtfw's own `pvtfw_variant_table_place` option) and replaces it with its own upload-card stack. Declared via the plugin header `Requires Plugins:` field and enforced at runtime by an admin notice if the plugin is missing or deactivated.
 
 ## Installation
 
@@ -26,26 +62,62 @@ WordPress (6.9.4+) / WooCommerce (10.6.2+) plugin that allows publishing special
 5. Select or create a **Media Category** - uploaded files will be assigned to this category
 6. Set **Max File Size (MB)** - maximum allowed upload size per file (1-500 MB, default 50)
 7. Optionally set **Min Image Resolution (px)** and **Max Image Resolution (px)** - width and height are interchangeable (portrait/landscape auto-detected). Leave empty to skip validation. RAW files that cannot be measured are skipped.
+7.5. Optionally set **Max Uploads** — cap on simultaneous uploads per customer visit for this product. Leave empty or 0 for unlimited.
 8. Copy the displayed **Shortcode** for use on custom pages
 
-### Product Detail Page
+### Product Detail Page (1.5.0+)
+
+On FPP products, the plugin **suppresses pvtfw's variant table** (via `remove_action()` on pvtfw's configured place hook) and renders its own UI in its stead:
+
+1. An upload **dropzone** (multi-file) at the top of the area.
+2. A **vertical stack of cards**, one per uploaded photo. Each card contains: thumbnail + filename + remove-upload (×) button + variant selector + `−/+` quantity stepper + live tier-discounted price with `−X%` discount badge + Add-to-Cart button.
+3. An **order-overview panel** at the bottom scoped to this product's FPP cart lines: thumbnail + variant label + quantity + line total + discount badge + per-line remove link + subtotal + "Go to checkout" button.
+
+The customer workflow is:
+
+1. Drop or pick one or more photos — they upload sequentially in the same card stack (progress bar overlaid on the thumbnail while uploading).
+2. The first uploaded photo becomes the product's hero gallery image; it's swapped back if all photos are removed.
+3. For each card: pick a variant and a quantity — the price updates live, a `−X%` badge appears when a quantity-discount tier is active.
+4. Click **Add to cart** on any card — the backing AJAX pre-stamps the `file_record_id` on `$cart_item_data` and calls `WC()->cart->add_to_cart()`. A new cart line (and eventually a new order item) is created for each (photo × variant × qty) combination. Adding the same combination twice increments the existing line's quantity.
+5. The order-overview panel refreshes in place with the new line.
+6. × on a card removes the upload (deletes the media file and DB record). × on an overview line removes the cart line only — the upload stays in the card stack so it can be re-added with different options.
+
+**Identity transport (1.5.0).** The plugin uses a single first-party HttpOnly cookie `studiou_fpp_batch` holding a 32-character random token. The token is minted the first time a photo finishes uploading and is reused by every subsequent upload in the same browser. Each `wp_studiou_fpp_files` row carries this token in a new `batch_token` column, so the server can enumerate exactly the photos this visitor has uploaded regardless of which WooCommerce session handler answers the Add-to-Cart request (classic cookie session vs. Store API Cart-Token — the cookie travels with *every* same-origin request). The legacy single-file `studiou_fpp_att_id` / `studiou_fpp_rec_id` cookies are still written by the upload handler as a read-fallback for the shortcode path; they'll be removed in 1.5.1.
+
+**Shortcode path.** `[studiou_free_photo_product id="X"]` keeps the 1.3.x single-file UX — one dropzone, one preview, add-to-cart via WC's native `variations_form` — because it embeds the product on arbitrary pages where the pvtfw suppression doesn't apply.
+
+### Quantity Discount Tiers (per variation)
 
-When a variable product has Free Photo enabled, the upload area automatically appears on the WooCommerce single product page **below** the product summary (below the variation table and Add to Cart buttons). The customer workflow is:
+Each variation can optionally define a **Quantity Discount Tiers** table in its admin edit panel. Each row is a pair:
 
-1. Upload a photo file via drag-and-drop or file browser (below the variation table)
-2. The product gallery image updates to show the uploaded photo preview
-3. Select a variant from the product variation table and click its **Add to Cart** button
-4. If no photo is uploaded, the server blocks the add-to-cart and shows an error notice
-5. The uploaded photo stays — the same photo can be used for multiple variants without re-uploading
+- **From Qty** — minimum quantity threshold (integer ≥ 1)
+- **Discount %** — percentage discount (0–100) applied to the entire cart line
 
-The uploaded file reference is stored in the WooCommerce session, so it works with any add-to-cart method (form POST, AJAX buttons, individual variation buttons). The session persists across add-to-cart actions, allowing the same photo to be used for multiple variants. The session is only cleared when the user explicitly removes the uploaded photo.
+The tier with the largest `From Qty` that is still less than or equal to the line's quantity wins (flat step function). When no row matches (or no tiers are defined), the variation's explicit WooCommerce price is used unchanged.
+
+Rules:
+- Tiers are evaluated **per cart line** — each line uses its own quantity, no cross-line summing. This matches the typical case where each line has its own uploaded photo.
+- Tiers are stored as post meta `_studiou_fpp_qty_tiers` on the `product_variation` post (PHP-serialized array, one sorted list per variation).
+- The feature is gated to FPP-enabled parent products. Non-FPP variable products are not affected.
+- The discounted unit price flows automatically into the cart subtotal, checkout, order line item, emails, invoices, and refunds — no special-casing anywhere else.
+
+**Frontend display** — designed for the **Product Variant Table for WooCommerce** (`pvtfw`) plugin, which is a hard dependency of this plugin:
+
+- Tier data for all variations of the current product is emitted as an inline `<script>window.studiouFppTierData = {…}</script>` via `wp_footer`.
+- A persistent amber **"Quantity discount available — save up to X%"** notice is rendered above the variants table on any FPP product that has at least one tiered variation (hooked on `woocommerce_single_product_summary` priority 25).
+- For each row of the `<table class="variant">` rendered by pvtfw, the frontend JS:
+  - Appends a compact tier summary strip beneath the price cell (e.g. `5+: −5% | 10+: −10% | 50+: −20%`).
+  - Binds the row's `input.qty` and `.qty-count` ± buttons so that changing the quantity updates the row's `.woocommerce-Price-amount` with the discounted unit price.
+  - Shows a green `−X%` "save" badge next to the price when a tier is active, and reverts the price back to the original HTML when the quantity drops below the smallest tier.
+- The frontend JS also contains a fallback branch that listens for WC's native `found_variation` / `reset_data` events on `form.variations_form`, so the plugin still degrades to sensible behavior if pvtfw is temporarily unavailable.
+- Per-row price updates are **display-only**. The authoritative pricing happens server-side in `woocommerce_before_calculate_totals` (priority 20), which calls `set_price()` on the cart item's product instance. The pvtfw AJAX add-to-cart handler only forwards `product_id`, `variation_id`, and `quantity` — it does not send the displayed price — so there is no risk of client-side tampering.
 
 Add-to-cart is blocked server-side until a photo is uploaded:
-- **Validation**: `woocommerce_add_to_cart_validation` (priority 1) checks the WC session for uploaded file data and blocks with an error notice if missing.
+- **Validation**: `woocommerce_add_to_cart_validation` (priority 1) calls `Studiou_WC_FPP_Single_Product::resolve_uploaded_file()` which looks at the WC session first and then falls back to the `studiou_fpp_att_id` / `studiou_fpp_rec_id` cookies, verifies the referenced row in `wp_studiou_fpp_files` matches and is not yet attached to an order, and blocks with an error notice if nothing resolves.
 - **Safety net**: `woocommerce_add_to_cart` action removes any FPP cart item that was added without a photo (catches edge cases where validation was bypassed).
 - Product detection uses `resolve_fpp_product_id()` which checks the product itself, its parent, and the variation's parent to handle all add-to-cart methods.
 
-On page reload, existing session state is restored — the upload preview and product gallery image are reconstructed from the server-side session data. When the photo is removed, the original product image is restored. Add-to-cart buttons always look and behave normally — blocking is entirely server-side.
+On page reload, the previously uploaded file is restored — the upload preview and product gallery image are reconstructed from whichever transport currently carries the reference (WC session or the cookie pair). When the photo is removed, both are cleared and the original product image is restored. Add-to-cart buttons always look and behave normally — blocking is entirely server-side.
 
 ### Shortcode
 
@@ -74,14 +146,17 @@ Navigate to **Products > Free Photo Files** to manage uploaded files.
 
 ### Features
 
-- Filter by status (Ready / Downloaded / Solved), product, or file name search
+- Filter by status (Ready / Downloaded / Solved), product, **order number** (exact match on the numeric `order_id`), or file-name search
+- Sortable columns: **Order**, **Status**, and **Date** (default sort is Date DESC). Toggle ASC/DESC by clicking the header. Sort and all active filters are preserved across pagination.
 - Inline status change per file via dropdown
 - Single file download (automatically marks status as "downloaded" if currently "ready")
 - Single file delete with media attachment cleanup
-- Bulk actions with confirmation dialogs: Download as ZIP, Set Status (Ready / Downloaded / Solved), Delete
+- Bulk actions with confirmation dialogs: Download ZIP, **Download as CSV**, Set Status (Ready / Downloaded / Solved), Delete
+- The CSV export writes a UTF-8-BOM-prefixed file (Excel-friendly) with columns: ID, File Name, Product, Variation ID, Order, Customer, Status, Date, File URL — mirroring the grid. The file is stored briefly in `studiou-fpp-chunks/`, served via a nonce-protected URL, and deleted after streaming.
+- File downloads always serve the **original, unscaled** file. WordPress auto-creates a `-scaled.jpg` for images above the big-image threshold (2560 px by default), but both single and ZIP downloads use `wp_get_original_image_path()` to return the raw upload.
 - Order number links (HPOS-compatible)
 - Customer name display
-- Pagination (30 files per page, preserves active filters across pages)
+- Pagination (30 files per page, preserves active filters and the active sort across pages)
 
 ### File Statuses
 
@@ -93,10 +168,10 @@ Navigate to **Products > Free Photo Files** to manage uploaded files.
 
 ## Cart and Order Integration
 
-The plugin integrates with WooCommerce's native add-to-cart flow using WC session storage. When a photo is uploaded, the file reference is stored in the WC session via AJAX. When any add-to-cart button is clicked, `woocommerce_add_cart_item_data` reads the file data from the session and attaches it to the cart item. The session persists so the same photo can be reused for multiple variants. The `woocommerce_add_to_cart_validation` filter ensures a photo is uploaded before the item can be added. This approach works with any theme or plugin that handles variable product display (individual variation buttons, dropdowns, tables, etc.).
+The plugin integrates with WooCommerce's native add-to-cart flow using the `resolve_uploaded_file()` helper that reads WC session first and then the `studiou_fpp_att_id` / `studiou_fpp_rec_id` cookie pair as a fallback. When a photo is uploaded, the file reference is written to both transports in the same request that assembles the file. When any add-to-cart button is clicked — whether it goes through classic admin-ajax or the WC Store API — `woocommerce_add_cart_item_data` runs the same resolver and attaches the file data to the cart item. Both the session and the cookies persist so the same photo can be reused for multiple variants. The `woocommerce_add_to_cart_validation` filter uses the same resolver to ensure a photo is uploaded before the item can be added. This approach works with any theme or plugin that handles variable product display (individual variation buttons, dropdowns, tables, Store API block cart, etc.).
 
 When a customer adds a photo product to the cart:
-- The cart item thumbnail shows the uploaded photo — works with both **classic cart** (`woocommerce_cart_item_thumbnail` filter) and **block cart** (`woocommerce_product_get_image_id` / `woocommerce_product_variation_get_image_id` filters that override the product image for the Store API)
+- The cart item thumbnail shows the uploaded photo — works with both **classic cart** (`woocommerce_cart_item_thumbnail` filter) and **block cart**. The block cart uses `woocommerce_get_cart_item_from_session` (1.5.4+), which clones the cart line's product instance and `set_image_id()`s the uploaded attachment onto it — per-line scope, so multiple cart lines sharing a `variation_id` each render their own uploaded photo.
 - The uploaded file name is shown as "Uploaded Photo" in the cart
 - On checkout, the file record is linked to the order and order item
 - In the order admin, order item thumbnails show the uploaded photo instead of the product image
@@ -155,6 +230,7 @@ The plugin creates one custom table on activation:
 | attachment_id | bigint | WP media attachment ID |
 | customer_id | bigint | WP user ID (0 for guests) |
 | session_key | varchar(64) | WC session key for guest users |
+| batch_token | varchar(32) | 1.5.0: per-visit opaque token identifying the upload batch |
 | file_name | varchar(255) | Original uploaded file name |
 | status | varchar(20) | ready / downloaded / solved |
 | created_at | datetime | Upload timestamp |
@@ -172,7 +248,8 @@ studiou-wc-free-photo-product/
 │   ├── class-studiou-wc-fpp-shortcode.php   Shortcode renderer
 │   ├── class-studiou-wc-fpp-single-product.php  Product page upload area
 │   ├── class-studiou-wc-fpp-cart.php        Cart/order integration (native WC flow)
-│   └── class-studiou-wc-fpp-admin.php       Admin summary page
+│   ├── class-studiou-wc-fpp-admin.php       Admin summary page
+│   └── class-studiou-wc-fpp-pricing.php     Per-variation quantity discount tiers
 ├── views/
 │   ├── frontend-product.php                 Shortcode template
 │   ├── single-product-upload.php            Upload area for product page
@@ -196,13 +273,14 @@ studiou-wc-free-photo-product/
 |---|---|---|
 | `studiou_wcfpp_upload_chunk` | Both | Upload a file chunk |
 | `studiou_wcfpp_remove_upload` | Both | Remove an uploaded file (pre-order) |
-| `studiou_wcfpp_set_upload_session` | Both | Store uploaded file reference in WC session |
-| `studiou_wcfpp_clear_upload_session` | Both | Clear uploaded file reference from WC session |
 | `studiou_wcfpp_add_media_category` | Admin | Create a new media category |
 | `studiou_wcfpp_update_status` | Admin | Update single file status |
 | `studiou_wcfpp_bulk_status` | Admin | Bulk update file statuses |
 | `studiou_wcfpp_delete_files` | Admin | Delete files (single or bulk) |
 | `studiou_wcfpp_download_zip` | Admin | Generate ZIP of selected files |
+| `studiou_wcfpp_download_csv` | Admin | Export selected file rows to a UTF-8 CSV (Excel-friendly) |
+| `studiou_wcfpp_add_file_to_cart` | Both | 1.5.0: per-card Add-to-Cart — adds one (file × variant × qty) as a new cart line |
+| `studiou_wcfpp_remove_cart_line` | Both | 1.5.0: remove one cart line from the order-overview panel (cart-only, upload stays) |
 
 ### WooCommerce Hooks Used
 
@@ -212,8 +290,7 @@ studiou-wc-free-photo-product/
 | `woocommerce_add_to_cart_validation` | Filter | Validates photo is uploaded |
 | `woocommerce_add_cart_item_data` | Filter | Captures file data into cart item |
 | `woocommerce_cart_item_thumbnail` | Filter | Replaces cart item thumbnail (classic cart) |
-| `woocommerce_product_get_image_id` | Filter | Returns uploaded photo as product image (block cart) |
-| `woocommerce_product_variation_get_image_id` | Filter | Returns uploaded photo as variation image (block cart) |
+| `woocommerce_get_cart_item_from_session` | Filter | Clones the cart line's product and `set_image_id()`s the uploaded photo onto it — per-line thumbnails for block cart (1.5.4+) |
 | `woocommerce_get_item_data` | Filter | Displays file name in cart |
 | `woocommerce_checkout_create_order_line_item` | Action | Saves file meta to order item |
 | `woocommerce_checkout_order_processed` | Action | Links file record to order (classic checkout) |
@@ -224,6 +301,10 @@ studiou-wc-free-photo-product/
 | `woocommerce_product_data_tabs` | Filter | Adds Free Photo product tab |
 | `woocommerce_product_data_panels` | Action | Renders Free Photo settings panel |
 | `woocommerce_process_product_meta` | Action | Saves Free Photo product meta |
+| `woocommerce_variation_options_pricing` (priority 20) | Action | Renders the quantity discount tier editor on each variation panel |
+| `woocommerce_save_product_variation` | Action | Sanitizes and saves quantity discount tiers per variation |
+| `woocommerce_available_variation` | Filter | Injects tier data into the variations form JSON so the frontend JS can render the tier table and live price |
+| `woocommerce_before_calculate_totals` (priority 20) | Action | Applies the quantity discount to the cart item's product via `set_price()` |
 
 ### Product Meta Keys
 
@@ -232,11 +313,18 @@ studiou-wc-free-photo-product/
 | `_studiou_fpp_enabled` | "yes" / "no" |
 | `_studiou_fpp_media_category` | Term ID of media category |
 | `_studiou_fpp_max_file_size` | Max upload size in MB |
+| `_studiou_fpp_max_uploads` | 1.5.0: max simultaneous uploads per visit (0 = unlimited) |
 | `_studiou_fpp_min_width` | Min image width in px (0 = no limit) |
 | `_studiou_fpp_min_height` | Min image height in px (0 = no limit) |
 | `_studiou_fpp_max_width` | Max image width in px (0 = no limit) |
 | `_studiou_fpp_max_height` | Max image height in px (0 = no limit) |
 
+### Variation Meta Keys
+
+| Key | Description |
+|---|---|
+| `_studiou_fpp_qty_tiers` | Array of `{from_qty, percent}` rows defining the quantity discount tier table for the variation (sorted ascending by `from_qty`) |
+
 ### Order Item Meta Keys
 
 | Key | Description |

+ 125 - 3
studiou-wc-free-photo-product/studiou-wc-free-photo-product.php

@@ -3,9 +3,10 @@
  * Plugin Name: QDR - Studiou WC Free Photo Product
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-free-photo-product
  * Description: Allows publishing special WooCommerce products with variants and custom raw image upload for photo printing
- * Version: 1.2.2
+ * Version: 1.5.14
  * Requires at least: 6.9.4
  * Requires PHP: 8.2
+ * Requires Plugins: woocommerce, product-variant-table-for-woocommerce
  * Author: Dalibor Votruba
  * Author URI: https://www.quadarax.com
  * License: GPL v2 or later
@@ -21,7 +22,7 @@ if (!defined('WPINC')) {
     die;
 }
 
-if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.2.2');
+if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.5.14');
 if (!defined('STUDIOU_WCFPP_PLUGIN_DIR')) define('STUDIOU_WCFPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_URL')) define('STUDIOU_WCFPP_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_BASENAME')) define('STUDIOU_WCFPP_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -50,6 +51,9 @@ class Studiou_WC_Free_Photo_Product {
     /** @var Studiou_WC_FPP_Admin */
     private $admin;
 
+    /** @var Studiou_WC_FPP_Pricing */
+    private $pricing;
+
     public function __construct() {
         $this->load_dependencies();
         add_action('admin_init', array($this, 'check_required_plugins'));
@@ -58,6 +62,20 @@ class Studiou_WC_Free_Photo_Product {
         add_action('wp_enqueue_scripts', array($this, 'register_frontend_assets'));
         $this->init_components();
         add_action('before_woocommerce_init', array($this, 'declare_hpos_compatibility'));
+        // Override WC core strings with FPP-specific wording (e.g. cart "Estimated total"
+        // → "Součet" in cs_CZ). Translation lives in our .po so it stays locale-aware.
+        add_filter('gettext_woocommerce', array($this, 'override_wc_strings'), 10, 2);
+    }
+
+    /**
+     * Reword selected WooCommerce core strings via our own textdomain so translations
+     * stay centralized in languages/studiou-wc-free-photo-product-*.po.
+     */
+    public function override_wc_strings($translation, $text) {
+        if ($text === 'Estimated total') {
+            return __('Total', 'studiou-wc-free-photo-product');
+        }
+        return $translation;
     }
 
     private function load_dependencies() {
@@ -68,16 +86,30 @@ class Studiou_WC_Free_Photo_Product {
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-single-product.php';
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-cart.php';
         require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-admin.php';
+        require_once STUDIOU_WCFPP_PLUGIN_DIR . 'includes/class-studiou-wc-fpp-pricing.php';
     }
 
     private function init_components() {
         $this->db = new Studiou_WC_FPP_DB();
+        $this->maybe_upgrade_db();
         $this->product = new Studiou_WC_FPP_Product();
         $this->upload = new Studiou_WC_FPP_Upload($this->db);
         $this->shortcode = new Studiou_WC_FPP_Shortcode();
-        $this->single_product = new Studiou_WC_FPP_Single_Product();
+        $this->single_product = new Studiou_WC_FPP_Single_Product($this->db);
         $this->cart = new Studiou_WC_FPP_Cart($this->db);
         $this->admin = new Studiou_WC_FPP_Admin($this->db);
+        $this->pricing = new Studiou_WC_FPP_Pricing();
+    }
+
+    /**
+     * Run dbDelta when the installed schema is older than the current plugin version.
+     * dbDelta handles adding the batch_token column and its KEY on existing installs.
+     */
+    private function maybe_upgrade_db() {
+        $installed = get_option('studiou_wcfpp_db_version', '');
+        if (version_compare((string) $installed, STUDIOU_WCFPP_VERSION, '<')) {
+            $this->db->create_tables();
+        }
     }
 
     public function check_required_plugins() {
@@ -88,6 +120,37 @@ class Studiou_WC_Free_Photo_Product {
                 echo '</p></div>';
             });
         }
+
+        if (!self::is_pvtfw_active()) {
+            add_action('admin_notices', function () {
+                echo '<div class="notice notice-error"><p>';
+                echo esc_html__('QDR - Studiou WC Free Photo Product requires the "Product Variant Table for WooCommerce" plugin to be installed and activated.', 'studiou-wc-free-photo-product');
+                echo '</p></div>';
+            });
+        }
+    }
+
+    /**
+     * Detects whether the Product Variant Table for WooCommerce (pvtfw) plugin is active,
+     * regardless of its main file name, by matching the active-plugins list on the folder slug.
+     */
+    public static function is_pvtfw_active() {
+        $slug = 'product-variant-table-for-woocommerce/';
+        $active = (array) get_option('active_plugins', array());
+        foreach ($active as $plugin) {
+            if (strpos($plugin, $slug) === 0) {
+                return true;
+            }
+        }
+        if (is_multisite()) {
+            $network = (array) get_site_option('active_sitewide_plugins', array());
+            foreach (array_keys($network) as $plugin) {
+                if (strpos($plugin, $slug) === 0) {
+                    return true;
+                }
+            }
+        }
+        return false;
     }
 
     public function declare_hpos_compatibility() {
@@ -142,6 +205,7 @@ class Studiou_WC_Free_Photo_Product {
                     'statusUpdated' => __('Status updated.', 'studiou-wc-free-photo-product'),
                     'error' => __('An error occurred.', 'studiou-wc-free-photo-product'),
                     'generatingZip' => __('Generating ZIP archive...', 'studiou-wc-free-photo-product'),
+                    'generatingCsv' => __('Generating CSV file...', 'studiou-wc-free-photo-product'),
                     'deleting' => __('Deleting...', 'studiou-wc-free-photo-product'),
                 ),
             ));
@@ -174,6 +238,7 @@ class Studiou_WC_Free_Photo_Product {
                     'statusUpdated' => __('Status updated.', 'studiou-wc-free-photo-product'),
                     'error' => __('An error occurred.', 'studiou-wc-free-photo-product'),
                     'generatingZip' => __('Generating ZIP archive...', 'studiou-wc-free-photo-product'),
+                    'generatingCsv' => __('Generating CSV file...', 'studiou-wc-free-photo-product'),
                     'deleting' => __('Deleting...', 'studiou-wc-free-photo-product'),
                 ),
             ));
@@ -181,6 +246,43 @@ class Studiou_WC_Free_Photo_Product {
     }
 
     public function register_frontend_assets() {
+        // WC block cart ("Estimated total" etc.) is React-rendered and pulls its strings
+        // from wp.i18n in the browser — the PHP gettext_woocommerce filter never fires for
+        // those strings. Patch the translation on the JS side.
+        //
+        // IMPORTANT: WC's wc-cart-checkout-base-js-translations inline bundle calls
+        // wp.i18n.setLocaleData(cartLocaleData, 'woocommerce') AFTER our override, which
+        // merges the WC-bundled "Estimated total": ["Odhadovaný součet"] back on top of
+        // ours. So a single one-shot override on wp-i18n is clobbered.
+        //
+        // We install a permanent override by:
+        //   1) re-applying after every future setLocaleData call via wp.hooks (if available), and
+        //   2) attaching another override as a `before` inline script on wc-cart-checkout-base,
+        //      which runs AFTER the translations bundle has merged its own data in.
+        $total_txt   = wp_json_encode(__('Total', 'studiou-wc-free-photo-product'));
+        $override_js = "( function() {";
+        $override_js .= " if ( ! window.wp || ! wp.i18n ) return;";
+        $override_js .= " var domain = 'woocommerce';";
+        $override_js .= " var txt = " . $total_txt . ";";
+        $override_js .= " function apply() { try {";
+        $override_js .= "  wp.i18n.setLocaleData( { 'Estimated total': [ txt ], 'Cart totals': [ txt ] }, domain );";
+        $override_js .= " } catch (e) {} }";
+        $override_js .= " apply();";
+        // Re-apply on every subsequent wp.i18n data change (covers WC's late bundle load).
+        $override_js .= " if ( wp.hooks && wp.hooks.addAction ) {";
+        $override_js .= "  wp.hooks.addAction( 'i18n.gettext_with_context_woocommerce', 'studiou-fpp/total-override', apply );";
+        $override_js .= "  wp.hooks.addAction( 'i18n.gettext_woocommerce', 'studiou-fpp/total-override', apply );";
+        $override_js .= " }";
+        // Also re-apply after DOMContentLoaded as a last-line safety net.
+        $override_js .= " if ( document.readyState === 'loading' ) {";
+        $override_js .= "  document.addEventListener( 'DOMContentLoaded', apply );";
+        $override_js .= " }";
+        $override_js .= " } )();";
+        wp_add_inline_script('wp-i18n', $override_js, 'after');
+        // The critical bit: override AFTER WC's cart-checkout-base translations bundle runs.
+        wp_add_inline_script('wc-cart-checkout-base', $override_js, 'after');
+        wp_add_inline_script('wc-cart-block-frontend', $override_js, 'after');
+
         wp_register_style(
             'studiou-wcfpp-frontend',
             STUDIOU_WCFPP_PLUGIN_URL . 'assets/css/frontend.css',
@@ -199,6 +301,16 @@ class Studiou_WC_Free_Photo_Product {
             'nonce' => wp_create_nonce('studiou-wcfpp-front-nonce'),
             'chunkSize' => STUDIOU_WCFPP_CHUNK_SIZE,
             'cartUrl' => wc_get_cart_url(),
+            'currency' => array(
+                // WooCommerce returns these with HTML entities (&#75;&#269; for Kč, &nbsp;
+                // embedded in priceFormat). Decoded here so the plain-text price formatter
+                // that fills <option> labels renders real characters instead of entities.
+                'symbol'            => function_exists('get_woocommerce_currency_symbol') ? html_entity_decode(get_woocommerce_currency_symbol(), ENT_QUOTES | ENT_HTML5, 'UTF-8') : '',
+                'priceFormat'       => function_exists('get_woocommerce_price_format') ? html_entity_decode(get_woocommerce_price_format(), ENT_QUOTES | ENT_HTML5, 'UTF-8') : '%1$s%2$s',
+                'decimalSeparator'  => function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.',
+                'thousandSeparator' => function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',',
+                'decimals'          => function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2,
+            ),
             'i18n' => array(
                 'uploading' => __('Uploading...', 'studiou-wc-free-photo-product'),
                 'uploadComplete' => __('Upload complete', 'studiou-wc-free-photo-product'),
@@ -211,6 +323,16 @@ class Studiou_WC_Free_Photo_Product {
                 'addToCartError' => __('Could not add to cart. Please try again.', 'studiou-wc-free-photo-product'),
                 'dragDropText' => __('Drag & drop your file here or click to browse', 'studiou-wc-free-photo-product'),
                 'removeFile' => __('Remove', 'studiou-wc-free-photo-product'),
+                'close' => __('Close', 'studiou-wc-free-photo-product'),
+                'enlarge' => __('Enlarge', 'studiou-wc-free-photo-product'),
+                'addToCart' => __('Add to cart', 'studiou-wc-free-photo-product'),
+                'maxUploadsReached' => __('Maximum number of uploads reached (%d).', 'studiou-wc-free-photo-product'),
+                'outOfStock' => __('out of stock', 'studiou-wc-free-photo-product'),
+                'error' => __('An error occurred.', 'studiou-wc-free-photo-product'),
+                'qtyTiersTitle' => __('Quantity Discount', 'studiou-wc-free-photo-product'),
+                'qtyTiersFromQty' => __('From Qty', 'studiou-wc-free-photo-product'),
+                'qtyTiersDiscount' => __('Discount', 'studiou-wc-free-photo-product'),
+                'qtyTiersUnitPrice' => __('Unit Price', 'studiou-wc-free-photo-product'),
             ),
         ));
     }

+ 60 - 11
studiou-wc-free-photo-product/views/admin-summary.php

@@ -9,10 +9,58 @@ if (!defined('WPINC')) {
 /** @var int $current_page */
 /** @var string $current_status */
 /** @var int $current_product */
+/** @var int $current_order */
 /** @var string $current_search */
 /** @var int $per_page */
 /** @var array $fpp_products */
+/** @var string $current_orderby */
+/** @var string $current_order_dir */
 $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
+
+// Build the base filter state that must survive on sort/pagination links
+$filter_state = array(
+    'post_type' => 'product',
+    'page'      => 'studiou-fpp-summary',
+);
+if ($current_status)  $filter_state['status']     = $current_status;
+if ($current_product) $filter_state['product_id'] = $current_product;
+if ($current_order)   $filter_state['order_id']   = $current_order;
+if ($current_search)  $filter_state['s']          = $current_search;
+
+/**
+ * Produce a <th> cell for a sortable column: toggles asc/desc and preserves filters.
+ */
+$sort_header = function ($column_key, $label) use ($filter_state, $current_orderby, $current_order_dir) {
+    $is_active = ($current_orderby === $column_key);
+    $next_dir  = ($is_active && $current_order_dir === 'ASC') ? 'DESC' : 'ASC';
+    if ($is_active && $current_order_dir === 'DESC') {
+        // If currently DESC, next click should flip to ASC
+        $next_dir = 'ASC';
+    } elseif (!$is_active) {
+        // Fresh click on a non-active column defaults to DESC for date/order, ASC feels odd
+        $next_dir = ($column_key === 'created_at' || $column_key === 'order_id') ? 'DESC' : 'ASC';
+    }
+
+    $args = $filter_state;
+    $args['orderby'] = $column_key;
+    $args['order']   = $next_dir;
+    $url = add_query_arg($args, admin_url('edit.php'));
+
+    $th_class = 'manage-column column-' . esc_attr($column_key) . ' sortable ';
+    if ($is_active) {
+        $th_class .= 'sorted ' . strtolower($current_order_dir);
+    } else {
+        $th_class .= 'desc';
+    }
+    ?>
+    <th class="<?php echo $th_class; ?>">
+        <a href="<?php echo esc_url($url); ?>">
+            <span><?php echo esc_html($label); ?></span>
+            <span class="sorting-indicator"></span>
+        </a>
+    </th>
+    <?php
+};
 ?>
 <div class="wrap studiou-fpp-admin-wrap">
     <h1><?php esc_html_e('Free Photo Files', 'studiou-wc-free-photo-product'); ?></h1>
@@ -24,6 +72,8 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
         <form method="get" action="<?php echo esc_url(admin_url('edit.php')); ?>">
             <input type="hidden" name="post_type" value="product" />
             <input type="hidden" name="page" value="studiou-fpp-summary" />
+            <input type="hidden" name="orderby" value="<?php echo esc_attr($current_orderby); ?>" />
+            <input type="hidden" name="order" value="<?php echo esc_attr($current_order_dir); ?>" />
 
             <select name="status">
                 <option value=""><?php esc_html_e('All Statuses', 'studiou-wc-free-photo-product'); ?></option>
@@ -39,6 +89,8 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
                 <?php endforeach; ?>
             </select>
 
+            <input type="number" name="order_id" min="0" step="1" value="<?php echo $current_order ? esc_attr($current_order) : ''; ?>" placeholder="<?php esc_attr_e('Order #', 'studiou-wc-free-photo-product'); ?>" class="studiou-fpp-filter-order" />
+
             <input type="search" name="s" value="<?php echo esc_attr($current_search); ?>" placeholder="<?php esc_attr_e('Search file name...', 'studiou-wc-free-photo-product'); ?>" />
 
             <button type="submit" class="button"><?php esc_html_e('Filter', 'studiou-wc-free-photo-product'); ?></button>
@@ -50,6 +102,7 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
         <select id="studiou-fpp-bulk-action">
             <option value=""><?php esc_html_e('Bulk Actions', 'studiou-wc-free-photo-product'); ?></option>
             <option value="download_zip"><?php esc_html_e('Download ZIP', 'studiou-wc-free-photo-product'); ?></option>
+            <option value="download_csv"><?php esc_html_e('Download as CSV', 'studiou-wc-free-photo-product'); ?></option>
             <option value="status_ready"><?php esc_html_e('Set Status: Ready', 'studiou-wc-free-photo-product'); ?></option>
             <option value="status_downloaded"><?php esc_html_e('Set Status: Downloaded', 'studiou-wc-free-photo-product'); ?></option>
             <option value="status_solved"><?php esc_html_e('Set Status: Solved', 'studiou-wc-free-photo-product'); ?></option>
@@ -76,10 +129,10 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
                 <th class="column-thumbnail"><?php esc_html_e('Preview', 'studiou-wc-free-photo-product'); ?></th>
                 <th class="column-filename"><?php esc_html_e('File Name', 'studiou-wc-free-photo-product'); ?></th>
                 <th class="column-product"><?php esc_html_e('Product', 'studiou-wc-free-photo-product'); ?></th>
-                <th class="column-order"><?php esc_html_e('Order', 'studiou-wc-free-photo-product'); ?></th>
+                <?php $sort_header('order_id', __('Order', 'studiou-wc-free-photo-product')); ?>
                 <th class="column-customer"><?php esc_html_e('Customer', 'studiou-wc-free-photo-product'); ?></th>
-                <th class="column-status"><?php esc_html_e('Status', 'studiou-wc-free-photo-product'); ?></th>
-                <th class="column-date"><?php esc_html_e('Date', 'studiou-wc-free-photo-product'); ?></th>
+                <?php $sort_header('status', __('Status', 'studiou-wc-free-photo-product')); ?>
+                <?php $sort_header('created_at', __('Date', 'studiou-wc-free-photo-product')); ?>
                 <th class="column-actions"><?php esc_html_e('Actions', 'studiou-wc-free-photo-product'); ?></th>
             </tr>
         </thead>
@@ -179,14 +232,10 @@ $base_url = admin_url('edit.php?post_type=product&page=studiou-fpp-summary');
                     )); ?>
                 </span>
                 <?php
-                // Preserve filters in pagination links
-                $pagination_args = array(
-                    'post_type'  => 'product',
-                    'page'       => 'studiou-fpp-summary',
-                );
-                if ($current_status) $pagination_args['status'] = $current_status;
-                if ($current_product) $pagination_args['product_id'] = $current_product;
-                if ($current_search) $pagination_args['s'] = $current_search;
+                // Preserve filters + active sort across pagination
+                $pagination_args = $filter_state;
+                $pagination_args['orderby'] = $current_orderby;
+                $pagination_args['order']   = $current_order_dir;
 
                 $pagination = paginate_links(array(
                     'base'      => add_query_arg('paged', '%#%', admin_url('edit.php?' . http_build_query($pagination_args))),

+ 107 - 0
studiou-wc-free-photo-product/views/single-product-order-overview.php

@@ -0,0 +1,107 @@
+<?php
+if (!defined('WPINC')) {
+    die;
+}
+
+/** @var WC_Cart|null $cart */
+/** @var int $product_id */
+
+// Collect cart lines that belong to this product and carry an FPP file reference.
+$lines = array();
+$subtotal = 0.0;
+if ($cart && $cart instanceof WC_Cart) {
+    foreach ($cart->get_cart() as $cart_item_key => $item) {
+        if (empty($item['studiou_fpp_file_record_id'])) {
+            continue;
+        }
+        $line_product_id = (int) ($item['product_id'] ?? 0);
+        if ($line_product_id !== (int) $product_id) {
+            continue;
+        }
+        $lines[] = array(
+            'key'  => $cart_item_key,
+            'item' => $item,
+        );
+        $subtotal += (float) ($item['line_total'] ?? 0.0) + (float) ($item['line_tax'] ?? 0.0);
+    }
+}
+?>
+<div class="studiou-fpp-overview" id="studiou-fpp-overview"
+     data-product-id="<?php echo esc_attr($product_id); ?>"
+     <?php echo empty($lines) ? ' style="display:none;"' : ''; ?>>
+
+    <h3 class="studiou-fpp-overview-title"><?php esc_html_e('Your order', 'studiou-wc-free-photo-product'); ?></h3>
+
+    <div class="studiou-fpp-overview-lines" id="studiou-fpp-overview-lines">
+        <?php if (!empty($lines)) : ?>
+            <?php foreach ($lines as $line) :
+                $item     = $line['item'];
+                $key      = $line['key'];
+                $thumb    = $item['studiou_fpp_thumb_url'] ?? '';
+                $preview  = '';
+                if (!empty($item['studiou_fpp_attachment_id'])) {
+                    $pv = wp_get_attachment_image_src((int) $item['studiou_fpp_attachment_id'], 'woocommerce_single');
+                    if ($pv) { $preview = $pv[0]; }
+                }
+                if (!$preview) { $preview = $thumb; }
+                $name     = $item['studiou_fpp_file_name'] ?? '';
+                $qty      = (int) ($item['quantity'] ?? 0);
+                $variation = wc_get_product($item['variation_id'] ?? 0);
+                $variant_label = '';
+                if ($variation) {
+                    $bits = array();
+                    foreach ($variation->get_attributes() as $k => $v) {
+                        if ($v === '') continue;
+                        $tax = str_replace('attribute_', '', $k);
+                        $term = taxonomy_exists($tax) ? get_term_by('slug', $v, $tax) : null;
+                        $raw = $term ? $term->name : urldecode((string) $v);
+                        $bits[] = html_entity_decode($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+                    }
+                    $variant_label = implode(' / ', $bits);
+                }
+                $unit_price  = $qty > 0 ? (float) $item['line_subtotal'] / $qty : 0.0;
+                $line_total  = (float) ($item['line_total'] ?? 0.0);
+                $base_price  = $variation ? (float) get_post_meta($variation->get_id(), '_price', true) : 0.0;
+                $discount_pct = ($base_price > 0 && $unit_price < $base_price)
+                    ? (int) round((1 - ($unit_price / $base_price)) * 100)
+                    : 0;
+
+                $remove_url = wp_nonce_url(
+                    add_query_arg('studiou-fpp-remove-line', $key, get_permalink($product_id)),
+                    'studiou-fpp-remove-line'
+                );
+                ?>
+                <div class="studiou-fpp-overview-line" data-cart-item-key="<?php echo esc_attr($key); ?>">
+                    <?php if ($thumb) : ?>
+                        <img class="studiou-fpp-overview-thumb" src="<?php echo esc_url($thumb); ?>" alt="<?php echo esc_attr($name); ?>" data-preview-url="<?php echo esc_url($preview); ?>" data-file-name="<?php echo esc_attr($name); ?>" />
+                    <?php endif; ?>
+                    <div class="studiou-fpp-overview-body">
+                        <div class="studiou-fpp-overview-name-row">
+                            <div class="studiou-fpp-overview-name"><?php echo esc_html($name); ?></div>
+                            <button type="button" class="studiou-fpp-overview-enlarge"><?php esc_html_e('Enlarge', 'studiou-wc-free-photo-product'); ?></button>
+                        </div>
+                        <div class="studiou-fpp-overview-variant"><?php echo esc_html($variant_label); ?></div>
+                        <div class="studiou-fpp-overview-meta">
+                            <span class="studiou-fpp-overview-qty"><?php echo esc_html(sprintf(__('Qty %d', 'studiou-wc-free-photo-product'), $qty)); ?></span>
+                            <span class="studiou-fpp-overview-price"><?php echo wp_kses_post(wc_price($line_total)); ?></span>
+                            <?php if ($discount_pct > 0) : ?>
+                                <span class="studiou-fpp-overview-badge">&minus;<?php echo esc_html($discount_pct); ?>%</span>
+                            <?php endif; ?>
+                        </div>
+                    </div>
+                    <a class="studiou-fpp-overview-remove" href="<?php echo esc_url($remove_url); ?>" title="<?php esc_attr_e('Remove', 'studiou-wc-free-photo-product'); ?>">&times;</a>
+                </div>
+            <?php endforeach; ?>
+        <?php endif; ?>
+    </div>
+
+    <div class="studiou-fpp-overview-footer">
+        <div class="studiou-fpp-overview-subtotal">
+            <span class="studiou-fpp-overview-subtotal-label"><?php esc_html_e('Subtotal:', 'studiou-wc-free-photo-product'); ?></span>
+            <span class="studiou-fpp-overview-subtotal-value"><?php echo wp_kses_post(wc_price($subtotal)); ?></span>
+        </div>
+        <a class="button studiou-fpp-overview-checkout" href="<?php echo esc_url(wc_get_checkout_url()); ?>">
+            <?php esc_html_e('Go to checkout', 'studiou-wc-free-photo-product'); ?>
+        </a>
+    </div>
+</div>

+ 51 - 0
studiou-wc-free-photo-product/views/single-product-price-table.php

@@ -0,0 +1,51 @@
+<?php
+if (!defined('WPINC')) {
+    die;
+}
+
+/** @var array<int,array{id:int,label:string,base_price:float,tiers:array,in_stock:bool}> $variants */
+?>
+<div class="studiou-fpp-price-table-wrap">
+    <h3 class="studiou-fpp-price-table-title"><?php esc_html_e('Price list', 'studiou-wc-free-photo-product'); ?></h3>
+    <table class="studiou-fpp-price-table">
+        <thead>
+            <tr>
+                <th class="studiou-fpp-price-table-variant"><?php esc_html_e('Variant', 'studiou-wc-free-photo-product'); ?></th>
+                <th class="studiou-fpp-price-table-price"><?php esc_html_e('Unit price', 'studiou-wc-free-photo-product'); ?></th>
+                <th class="studiou-fpp-price-table-discount"><?php esc_html_e('Quantity discount', 'studiou-wc-free-photo-product'); ?></th>
+            </tr>
+        </thead>
+        <tbody>
+            <?php foreach ($variants as $v) :
+                $row_classes = array('studiou-fpp-price-table-row');
+                if (empty($v['in_stock'])) {
+                    $row_classes[] = 'studiou-fpp-price-table-row-oos';
+                }
+                ?>
+                <tr class="<?php echo esc_attr(implode(' ', $row_classes)); ?>">
+                    <td class="studiou-fpp-price-table-variant"><?php echo esc_html($v['label']); ?></td>
+                    <td class="studiou-fpp-price-table-price"><?php echo wp_kses_post(wc_price($v['base_price'])); ?></td>
+                    <td class="studiou-fpp-price-table-discount">
+                        <?php if (!empty($v['tiers'])) : ?>
+                            <ul class="studiou-fpp-price-table-tiers">
+                                <?php foreach ($v['tiers'] as $tier) :
+                                    $from = isset($tier['from_qty']) ? (int) $tier['from_qty'] : 0;
+                                    $pct  = isset($tier['percent'])  ? (float) $tier['percent'] : 0.0;
+                                    if ($from <= 0 || $pct <= 0) continue;
+                                    $pct_str = rtrim(rtrim(number_format($pct, 2, '.', ''), '0'), '.');
+                                    ?>
+                                    <li class="studiou-fpp-price-table-tier">
+                                        <span class="studiou-fpp-price-table-tier-qty"><?php echo esc_html($from); ?>+</span>
+                                        <span class="studiou-fpp-price-table-tier-pct">&minus;<?php echo esc_html($pct_str); ?>&nbsp;%</span>
+                                    </li>
+                                <?php endforeach; ?>
+                            </ul>
+                        <?php else : ?>
+                            <span class="studiou-fpp-price-table-no-discount">&mdash;</span>
+                        <?php endif; ?>
+                    </td>
+                </tr>
+            <?php endforeach; ?>
+        </tbody>
+    </table>
+</div>

+ 29 - 27
studiou-wc-free-photo-product/views/single-product-upload.php

@@ -5,50 +5,52 @@ if (!defined('WPINC')) {
 
 /** @var int $product_id */
 /** @var int $max_file_size */
+/** @var int $max_uploads */
 ?>
-<div class="studiou-fpp-upload-wrap" data-product-id="<?php echo esc_attr($product_id); ?>" data-max-file-size="<?php echo esc_attr($max_file_size); ?>">
+<div class="studiou-fpp-upload-wrap"
+     data-product-id="<?php echo esc_attr($product_id); ?>"
+     data-max-file-size="<?php echo esc_attr($max_file_size); ?>"
+     data-max-uploads="<?php echo esc_attr($max_uploads); ?>">
+
+    <h3 class="studiou-fpp-heading"><?php esc_html_e('Upload Your Photos', 'studiou-wc-free-photo-product'); ?></h3>
 
-    <h3><?php esc_html_e('Upload Your Photo', 'studiou-wc-free-photo-product'); ?></h3>
     <?php
     $custom_info = Studiou_WC_FPP_Product::get_product_upload_info_text($product_id);
     if (!empty($custom_info)) :
-    ?>
+        ?>
         <p class="studiou-fpp-upload-info"><?php echo nl2br(esc_html($custom_info)); ?></p>
     <?php else : ?>
         <p class="studiou-fpp-upload-info">
-            <?php echo esc_html(sprintf(
-                __('Maximum file size: %s MB. Accepted formats: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP.', 'studiou-wc-free-photo-product'),
-                $max_file_size
-            )); ?>
+            <?php
+            if ($max_uploads > 0) {
+                echo esc_html(sprintf(
+                    /* translators: %1$d: max file size in MB, %2$d: max number of uploads */
+                    __('Upload up to %2$d photos (max %1$d MB each). Accepted formats: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP.', 'studiou-wc-free-photo-product'),
+                    (int) $max_file_size,
+                    (int) $max_uploads
+                ));
+            } else {
+                echo esc_html(sprintf(
+                    /* translators: %d: max file size in MB */
+                    __('Maximum file size: %d MB. Accepted formats: JPEG, PNG, TIFF, BMP, PSD, RAW (CR2, NEF, ARW, DNG, ORF, RW2, RAF), WebP.', 'studiou-wc-free-photo-product'),
+                    (int) $max_file_size
+                ));
+            }
+            ?>
         </p>
     <?php endif; ?>
 
     <div class="studiou-fpp-upload-area" id="studiou-fpp-dropzone">
         <div class="studiou-fpp-upload-prompt">
             <span class="studiou-fpp-upload-icon dashicons dashicons-upload"></span>
-            <p><?php esc_html_e('Drag & drop your file here or click to browse', 'studiou-wc-free-photo-product'); ?></p>
-        </div>
-        <input type="file" id="studiou-fpp-file-input" class="studiou-fpp-file-input"
-            accept=".jpg,.jpeg,.png,.tiff,.tif,.bmp,.psd,.cr2,.nef,.arw,.dng,.orf,.rw2,.raf,.webp" />
-    </div>
-
-    <div class="studiou-fpp-progress" style="display:none;">
-        <div class="studiou-fpp-progress-bar">
-            <div class="studiou-fpp-progress-fill" style="width:0%"></div>
-        </div>
-        <span class="studiou-fpp-progress-text">0%</span>
-    </div>
-
-    <div class="studiou-fpp-preview" style="display:none;">
-        <div class="studiou-fpp-preview-image">
-            <img src="" alt="<?php esc_attr_e('Preview', 'studiou-wc-free-photo-product'); ?>" />
-        </div>
-        <div class="studiou-fpp-preview-info">
-            <span class="studiou-fpp-preview-name"></span>
-            <button type="button" class="button studiou-fpp-remove-file"><?php esc_html_e('Remove', 'studiou-wc-free-photo-product'); ?></button>
+            <p><?php esc_html_e('Drag & drop photos here, or click to choose files', 'studiou-wc-free-photo-product'); ?></p>
         </div>
+        <input type="file" id="studiou-fpp-file-input" class="studiou-fpp-file-input" multiple
+               accept=".jpg,.jpeg,.png,.tiff,.tif,.bmp,.psd,.cr2,.nef,.arw,.dng,.orf,.rw2,.raf,.webp" />
     </div>
 
     <div class="studiou-fpp-messages"></div>
 
+    <div class="studiou-fpp-cards" id="studiou-fpp-cards"></div>
+
 </div>

+ 153 - 16
studiou-wc-ord-print-statuses/CLAUDE.md

@@ -8,12 +8,136 @@ This is a WordPress plugin that extends WooCommerce functionality to manage prin
 
 **Plugin Details:**
 - Text Domain: `studiou-wc-ord-print-statuses`
-- Current Version: 1.3.2
+- Current Version: 1.5.0
 - Requires: WordPress 5.8+, WooCommerce 3.0+, PHP 7.2+
-- Tested with: WordPress 6.8.3, WooCommerce 10.2.2
+- Tested with: WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled)
+
+## Documentation
+
+User-facing and historical documentation lives at the project root and under `docs/`. Consult these alongside this file before making changes:
+
+- [`README.md`](README.md) — Primary, up-to-date plugin documentation (features, install, CSV formats, architecture, changelog).
+- [`docs/readme.md`](docs/readme.md) — Previous user-facing readme; retained for history.
+- [`docs/instructions.txt`](docs/instructions.txt) — Original feature specification used to scaffold the plugin. Useful when verifying intended behaviour against current implementation.
+- [`docs/translations.txt`](docs/translations.txt) — EN → CS translation reference for the bundled `cs_CZ` locale.
+- [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2. Every finding listed there is resolved in 1.4.0 (see README changelog).
+- [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0. All in-scope findings are resolved in 1.4.1; the doc carries per-item status annotations.
+- [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Fresh-eye analytical review of v1.4.1. All in-scope findings are resolved in 1.4.2; per-item status annotations inside.
+- [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2. All in-scope findings are resolved in 1.4.3; per-item status annotations inside.
+- [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) — Fresh-eye analytical review of v1.4.3. All in-scope findings are resolved in 1.4.4; per-item status annotations inside.
+- [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) — Fresh-eye analytical review of v1.4.4. Mediums and lows are addressed in 1.4.5; per-item status annotations inside.
+
+When adding new documentation files, place them in `docs/` and add a one-line pointer here.
 
 ## Recent Changes
 
+### Version 1.5.0 (2026-05-12)
+
+**New feature:** per-order-item print status. See `README.md` §"Per-order-item print status" and `docs/feature-order-item-status-analysis.md` for the full design rationale.
+
+- **New manager:** `Order_Item_Status_Manager` (`includes/class-order-item-status-manager.php`). Renders the per-item Print Status column on the order edit screen, persists POST changes, and runs the combined propagation + completion guard on `woocommerce_order_status_changed` at priority 5.
+- **Storage:** `_print_status` meta key on each `WC_Order_Item_Product`, in `{$wpdb->prefix}woocommerce_order_itemmeta`. Lazy default = `pending-print` (no migration needed).
+- **Coupling:** order → `wc-in-print` propagates `pending-print` items to `in-print`; order → `wc-completed` is gated by every product item being in `done-print` or `skip-print`. All other transitions leave items alone.
+- **`Studiou_DB_Manager::import_delivered_protocol()`** now calls `advance_remaining_to_done()` before the order-status flip so the completion guard is satisfied. Surfaces the total items advanced in the import summary.
+- **`Studiou_DB_Manager::get_orders_for_printing()`** adds `prod_print_status` column via `LEFT JOIN` on `woocommerce_order_itemmeta` filtered to `_print_status`, with `COALESCE(..., 'pending-print')` for the lazy default.
+- **CSV export** now includes the new column. Print-shop tooling that consumes the CSV needs to either consume or ignore `prod_print_status`.
+- **Bulk action behaviour change:** "Set Status to In Printing" now propagates items via the listener (no explicit code in Bulk_Actions_Manager). "Set Status to Prepare to Printing" remains order-only.
+
+### Version 1.4.5 (2026-05-12)
+
+Defensive-hardening release resolving the findings in `docs/revise-1.4.4.md`. No critical or high findings; this round closes mediums and lows.
+
+- **`normalise_csv_date()` fast path** now runs `checkdate()` + hour/minute/second bounds before accepting the regex match. Bogus inputs like `"2026-13-45T25:99"` no longer slip through to be stored verbatim.
+- **`emit_import_summary()`** adds an "info: no actionable rows" notice when both `$updated` and `$errors` are zero. Closes the silent-import path when every CSV row had an empty `order_no` after dedup.
+- **`csv_escape()`** trims leading whitespace before the first-char check. `" =CMD"`-style bypasses are defused.
+- **`parse_csv()`** rejects CSVs with normalised header collisions (e.g., `"Order No"` and `"order_no"` both → `order_no`). Avoids silent `array_combine` overwrite.
+- **`bulk_set_print_status` note dispatch** lifted to an explicit `array(slug => note)` map. Future-contributor trap closed.
+
+Deferred (low-impact): main plugin instance lifecycle, `woocommerce_missing_notice` mid-request edge case, theoretical MySQL collation mismatch on the slug join (now mentioned in README "Known limitations").
+
+### Version 1.4.4 (2026-05-12)
+
+Bug-fix release. Notable code changes resolving findings in `docs/revise-1.4.3.md`:
+
+- **`Studiou_DB_Manager::normalise_csv_date()`** rewritten — drops `strtotime` entirely. Fast path: regex reformat for ISO-like inputs (no timezone shift). Fallback: `DateTimeImmutable($raw, wp_timezone())->setTimezone(wp_timezone())`. Eliminates the WP-UTC-default + wp_date timezone double-handling that caused a Prague-shifted 14:30 → 16:30 drift in 1.4.2/1.4.3. **Zero `strtotime` calls in `includes/` now** — the recurring bug pattern is structurally eliminated.
+- **HPOS table pre-check.** `get_orders_for_printing()` now bails with an admin notice when `{$wpdb->prefix}wc_orders` is missing (HPOS disabled), parallel to the existing check on `wc_order_product_lookup`.
+- **Bulk-action order notes.** `bulk_set_print_status` passes "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." as the `update_status` note. Audit trail now consistent across bulk and import paths.
+- **Column rendering perf.** `Custom_Columns_Manager` uses the WC_Order object directly under HPOS (no `wc_get_order` call) and memoises by ID per request under legacy CPT. Saves ~150 lookups per 50-orders page.
+- **Import summary suppression.** `Import_Manager::emit_import_summary()` skips the "%d orders updated successfully" notice when no rows changed.
+- **`call_user_func` → direct callable** in `run_import`.
+- **`before_woocommerce_init` callback** converted from anonymous to named (`studiou_wc_ord_print_statuses_declare_hpos_compat`) so it can be `remove_action`-ed.
+
+### Version 1.4.3 (2026-05-12)
+
+Bug-fix release resolving the fresh-eye findings in `docs/revise-1.4.2.md`. Notable code changes:
+
+- **Symmetric terminal-status guard in `import_delivered_protocol`.** Previously absent — `cancelled`/`refunded`/`failed` orders in the delivered CSV were silently reactivated to `completed`. Now skipped with a per-row error.
+- **Import cap aligned with bulk-action cap.** `add_submenu_page` and the handler check both use `edit_shop_orders` (was `manage_woocommerce`) so Shop Managers can run protocol imports.
+- **CSV formula-injection defence.** `Studiou_DB_Manager::csv_escape()` prefixes any cell value beginning with `=`, `+`, `-`, `@`, `\t`, or `\r` with a single quote.
+- **`table_exists()` uses `$wpdb->esc_like($table)`.** `SHOW TABLES LIKE` interprets `_` and `%` as wildcards; conventional WP prefixes (e.g., `wp_test_`) contain `_`. Mitigated previously by post-query equality check; now mitigated correctly at the query layer.
+- **Datetime form values are range-validated.** `Order_Fields_Manager::datetime_local_to_mysql()` runs `checkdate()` plus hour/minute/second bounds. Direct-POST or DevTools-edited bogus values are rejected.
+- **`qty` normalised in PHP.** `SUM(DECIMAL(8,4))` returns `"2.0000"` — the post-processing step formats whole numbers as integers and trims trailing zeros from decimals.
+- **Order notes on import status changes.** Both import paths now pass an explanatory note to `update_status`.
+- **Per-row logging symmetry.** Import handlers now log per-row to `UtilsLog::log`, matching the bulk-action path.
+- **Import summary error dedup.** `emit_import_summary` shows unique error strings; the count is the total.
+- **`UtilsLog::render_notices` uses `esc_html`** instead of `wp_kses_post` — tighter escape, all current call sites pass plain text.
+- **Removed `set_order_processing_status` + its `woocommerce_payment_complete` hook.** Dead code on modern WC — WC sets the status itself before firing the action; our handler was a no-op or skip in every path. Related translation entry removed.
+
+### Version 1.4.2 (2026-05-12)
+
+Bug-fix release addressing the fresh-eye findings in `docs/revise-1.4.1.md`. Notable code changes:
+
+- **`Custom_Columns_Manager::format_meta_date()`** — switched to `DateTimeImmutable(..., wp_timezone())` + `wp_date()`. The "To Print Date" / "In Print Date" columns no longer drift by the WP-vs-server-timezone offset.
+- **Export SQL slug join** — `Studiou_DB_Manager::get_orders_for_printing()` now joins `product_variations_name.slug = product_variations_attr.meta_value` (WC stores the slug in `attribute_pa_format` postmeta, not the term name). Display still uses `MIN(terms.name)` to surface the human-readable label.
+- **Export SQL `SUM(qty)`** — was `MIN(qty)`. A single order can have multiple lookup rows for the same (product, variation) when the customer added it twice without cart-merge; `MIN()` silently undercounted.
+- **`LEFT JOIN product_variations`** — simple (non-variable) products were silently excluded by the previous inner join. They now appear with blank `prod_var` / `prod_var_type`.
+- **Image URL via WP API** — drops `wp_posts.guid` (codex warns it's not necessarily a URL). Selects `_thumbnail_id`, resolves via `wp_get_attachment_url()`. Offloaded-media-friendly.
+- **`normalise_csv_date()` uses `wp_date()`** — matches `current_time('mysql')` elsewhere instead of `date()`'s server-timezone interpretation.
+- **`wc_order_product_lookup` pre-check** — bails with a clear admin notice if WC Analytics is disabled and the lookup table is missing.
+- **`SET SESSION group_concat_max_len`** uses `GREATEST(@@SESSION.group_concat_max_len, 65535)` — never lowers a higher pre-existing value.
+- **Server-side `.csv` extension check** on protocol uploads (defense-in-depth).
+- **`Order_Status_Manager::add_custom_order_statuses_to_list`** falls back to appending the custom statuses at the end of the dropdown if `wc-processing` is absent.
+- **`parse_csv()`** drops `@fopen` suppression; opens explicitly and logs on failure.
+- **Doc:** README adds a "Known limitations" section and clarifies what `order_no` means in CSV protocols. CLAUDE.md description of the `attribute_pa_format` join corrected (slug, not name — see below).
+
+### Version 1.4.1 (2026-05-12)
+
+Bug-fix release resolving findings from `docs/revise-1.4.0.md`. Key code changes:
+
+- **SQL strict-mode safety** in `Studiou_DB_Manager::get_orders_for_printing()` — non-aggregated SELECT columns wrapped in `MIN()`; `SET SESSION group_concat_max_len = 65535` before the query.
+- **CSV BOM handling** in `parse_csv()` — strips a leading UTF-8 BOM (which our own export writes); `normalise_header()` also defensively strips one.
+- **Datetime round-trip** in `Order_Fields_Manager` — switched to pure string reformatting (`YYYY-MM-DD HH:MM:SS` ↔ `YYYY-MM-DDTHH:MM`). No `strtotime()`/`date()` so server-vs-WP-timezone drift is eliminated.
+- **Capability check** added to `Order_Fields_Manager::save_custom_order_fields()`.
+- **Single order note** in `set_order_processing_status()` — the second `add_order_note()` call was removed.
+- **Bulk action UX** — `notify_moved_count()` helper suppresses notices when count is zero.
+- **`output_csv()` safety** — fails loudly via `wp_die()` if `headers_sent()`, logging the source file/line.
+- **PHP 8.4 compatibility** — `fputcsv`/`fgetcsv` now pass an explicit empty-string escape character.
+- **Import dispatch map** — `Import_Manager::protocol_dispatch()` centralises the protocol → handler mapping; `run_import()` uses explicit `return $this->redirect_back()` on every non-happy branch.
+- **Dedup case-insensitive** — `dedupe_by_order_no()` lowercases keys.
+- **`normalise_csv_date()`** returns `''` and logs on parse failure (was returning the raw unparseable string).
+- **Notice TTL** bumped 60 s → 300 s.
+
+Deferred (not addressed in 1.4.1): live HPOS search-filter verification, `Network:` header, `uninstall.php`, block-based order edit screen.
+
+### Version 1.4.0 (2026-05-12)
+
+Comprehensive fix-up release addressing every finding in `docs/revise-1.3.2.md`:
+
+- **Search by External Order Number** is now functional. Reworked `Order_Search_Manager` uses `woocommerce_shop_order_search_fields` (legacy) and `woocommerce_order_table_search_query_meta_keys` (HPOS) — the standard "Search orders" input now matches `external_ref_ord_no`. Removed all broken hooks (`parse_query` with CPT-only guard, columns-filter-as-search-fields-filter, search input rendered inside table cells).
+- **HPOS metadata stamping** fixed in `Order_Status_Manager::handle_status_transitions()` — now uses `wc_get_order() + update_meta_data() + save()` instead of `update_post_meta()`.
+- **`set_order_processing_status()`** now treats `to-print`, `in-print`, and `completed` as protected; late `woocommerce_payment_complete` no longer demotes print-state orders.
+- **Bulk export atomicity:** CSV is built fully in memory before any status change. UTF-8 BOM prepended; `Content-Type: text/csv; charset=utf-8`; `nocache_headers()`.
+- **Export SQL** now uses `$wpdb->prepare()` with `%d` placeholders (replaces the `intval()`-only defense). `GROUP_CONCAT(DISTINCT t.name)` + `GROUP BY orders.id, product_id, variation_id` collapses multi-category products to one row per line item.
+- **CSV import** deduplicates by `order_no`; headers are normalised (lowercased, internal whitespace → `_`), so both spec casing (`Order No`) and the documented lowercase form (`order_no`) work; orders in terminal statuses are skipped with a per-row error.
+- **File-upload validation:** `$_FILES[...]['error']`, `is_uploaded_file()`, and non-empty header row are now checked server-side.
+- **Cap checks:** bulk-action handler guards with `current_user_can('edit_shop_orders')` before dispatch.
+- **UtilsLog:** `log()` guard relaxed to `defined('WP_DEBUG') && WP_DEBUG`; `message()` implemented as per-user transient → `admin_notices` hook. Bulk actions and imports now produce visible feedback.
+- **Order_Fields_Manager:** added `external_ref_ord_date` field to the Print Information panel; datetime values round-trip through `strtotime()` → MySQL datetime.
+- **Custom_Columns_Manager:** column-content callback parameter renamed to `$order_or_id` to reflect HPOS contract; passes through `wc_get_order()`.
+- **Bootstrap:** `STUDIOU_WC_OPS_VERSION` / `STUDIOU_WC_OPS_FILE` constants defined. Text domain loader pinned to `plugins_loaded` priority 5; plugin init at default priority 10 — guarantees translations are loaded before any manager instantiates.
+- **Translations:** `docs/translations.txt` extended with previously-missing strings. The `.mo` file must be rebuilt by hand (e.g. `wp i18n make-mo languages/` or Poedit); 1.4.0 ships only the source updates.
+- **Docs:** README architecture, install, usage, and changelog updated. Misleading "SQL prepare via intval" claim in CLAUDE.md removed.
+
 ### Version 1.3.2 (2025-10-18)
 **Bug Fix: Empty prod_var_type column in CSV export**
 
@@ -23,10 +147,10 @@ Fixed an issue where the `prod_var_type` column was always empty in the "Prepare
 The SQL query in `Studiou_DB_Manager::get_orders_for_printing()` was incorrectly using the `wc_product_attributes_lookup` table to retrieve product variation attributes. This table stores product-level attribute data (for parent products), not the specific attribute values for individual variations.
 
 **Solution:**
-Modified the query (lines 55-65 in `includes/class-db-manager.php`) to:
+Modified the query (in `includes/class-db-manager.php`) to:
 1. Join with `wp_postmeta` table instead of `wc_product_attributes_lookup`
 2. Look for meta_key = 'attribute_pa_format' which stores the variation's format attribute value
-3. Join with `wp_terms` using the slug field (matching against meta_value) instead of term_id
+3. Join `wp_terms.slug = postmeta.meta_value` (WC stores the slug in `attribute_pa_format`, not the term name). Display uses `MIN(wp_terms.name)` to surface the human-readable label. *(Originally written as "name" — corrected to "slug" in 1.4.2 when the live data confirmed the slug shape; see `docs/revise-1.4.1.md` §2.2.)*
 
 **HPOS Compatibility Declaration**
 
@@ -60,7 +184,8 @@ The plugin uses a manager-based architecture where the main plugin class (`Studi
 
 2. **Order_Fields_Manager** (`includes/class-order-fields-manager.php`)
    - Adds "Print Information" section to order edit pages
-   - Manages fields: `to_print_date`, `in_print_date`, `external_ref_ord_no`, `external_ref_ord_delivered`
+   - Manages fields: `to_print_date`, `in_print_date`, `external_ref_ord_no`, `external_ref_ord_date`, `external_ref_ord_delivered`
+   - Datetime fields round-trip via `strtotime()`/MySQL datetime so admin edits stay consistent with values written by imports.
 
 3. **Bulk_Actions_Manager** (`includes/class-bulk-actions-manager.php`)
    - Registers bulk actions: "Prepare to Printing Export", "Set Status to Prepare to Printing", "Set Status to In Printing"
@@ -75,24 +200,36 @@ The plugin uses a manager-based architecture where the main plugin class (`Studi
    - Handles Delivered Protocol (sets orders to "completed" with delivery timestamp)
 
 6. **Order_Search_Manager** (`includes/class-order-search-manager.php`)
-   - Extends WooCommerce order search to include external reference order numbers
+   - Extends WooCommerce order search to include the `external_ref_ord_no` meta key.
+   - Hooks `woocommerce_shop_order_search_fields` (legacy CPT) and `woocommerce_order_table_search_query_meta_keys` (HPOS) with the same callback. No separate UI input — the standard "Search orders" box handles it.
 
 7. **Studiou_DB_Manager** (`includes/class-db-manager.php`)
    - Centralized database operations using static methods
    - Contains complex SQL for export query joining orders, products, variations, categories, and images
    - Handles CSV parsing and protocol imports
 
+8. **Order_Item_Status_Manager** (`includes/class-order-item-status-manager.php`)
+   - New in 1.5.0. Owns the per-line-item print status (`_print_status` meta on `WC_Order_Item_Product`).
+   - Four status constants: `STATUS_PENDING` / `STATUS_IN` / `STATUS_DONE` / `STATUS_SKIP`.
+   - Renders the "Print Status" column on the order edit screen items table.
+   - Persists POST changes via `woocommerce_process_shop_order_meta`.
+   - Combined propagation + completion guard listener on `woocommerce_order_status_changed` (priority 5, with static recursion-guard flag).
+     - Order → `wc-in-print`: `pending-print` items advance to `in-print`.
+     - Order → `wc-completed`: gated; blocked if any product item is still `pending-print` or `in-print`.
+     - All other order transitions: no automatic item change.
+   - Exposes `advance_pending_to_in()` and `advance_remaining_to_done()` for protocol imports.
+
 ### Key Database Operations
 
 The export query in `Studiou_DB_Manager::get_orders_for_printing()` performs a complex join across:
 - `wp_wc_orders` (order data)
-- `wp_wc_order_product_lookup` (order products)
-- `wp_posts` (products and variations)
-- `wp_postmeta` (product variation attributes - specifically 'attribute_pa_format')
+- `wp_wc_order_product_lookup` (order products — depends on WC Analytics being enabled; the method pre-checks this table)
+- `wp_posts` (products and variations — variations via `LEFT JOIN` so simple products are included)
+- `wp_postmeta` (product variation attributes, specifically `attribute_pa_format` whose value is the term **slug**)
 - `wp_terms` and `wp_term_taxonomy` (product categories and variation attribute values)
-- `wp_postmeta` (product images via '_thumbnail_id' meta)
+- `wp_postmeta` (product `_thumbnail_id` — image URL resolved in PHP via `wp_get_attachment_url()`, not from `wp_posts.guid`)
 
-This query extracts: order number, product category, product name, variation name, variation type, image URL, quantity, and customer email.
+The query extracts: order number, product category, product name, variation name, variation type, image URL, **summed** quantity (per `(order, product, variation)` group), and customer email. Non-aggregated columns are wrapped in `MIN()` (or `SUM()` for qty) to remain compliant with `ONLY_FULL_GROUP_BY`. `group_concat_max_len` is raised via `SET SESSION ... = GREATEST(@@SESSION..., 65535)` before the query.
 
 ### Order Metadata Keys
 
@@ -104,11 +241,11 @@ This query extracts: order number, product category, product name, variation nam
 
 ## Development Commands
 
-### Logging
+### Logging & user notices
 
-The plugin includes a simple logging utility (`includes/utils-log.php`):
-- `UtilsLog::log($message)` - Logs to WordPress error log when `WP_DEBUG` is enabled
-- Check logs in `wp-content/debug.log` if `WP_DEBUG_LOG` is enabled
+The plugin includes a logging + notice utility (`includes/utils-log.php`):
+- `UtilsLog::log($message)` - Writes to the WordPress error log when `WP_DEBUG` is truthy (guarded as `defined('WP_DEBUG') && WP_DEBUG`). Check `wp-content/debug.log` when `WP_DEBUG_LOG` is on.
+- `UtilsLog::message($message, $type)` - Queues a dismissible WordPress admin notice (`info` / `success` / `warning` / `error`) for the current user via a 60-second transient + `admin_notices` hook. Survives the redirect that follows bulk actions and imports.
 
 ### WordPress/WooCommerce Hooks
 
@@ -157,6 +294,6 @@ The plugin supports internationalization:
 
 3. **CSV Output**: The `prepare_to_printing_export` bulk action terminates with `exit` after outputting CSV headers, preventing further PHP execution
 
-4. **Database Queries**: Direct SQL queries use `$wpdb->prepare()` implicitly through `intval()` for order IDs. Always sanitize inputs when modifying database operations.
+4. **Database Queries**: The export query in `Studiou_DB_Manager::get_orders_for_printing()` uses `$wpdb->prepare()` with `%d` placeholders for the order ID list. When adding new SQL, prefer `$wpdb->prepare()` over inline coercion. Order IDs are also pre-filtered with `array_map('intval', …)` as defense in depth.
 
 5. **Admin Menu**: Import functionality is added as a submenu under WooCommerce admin menu, requires `manage_woocommerce` capability

+ 406 - 0
studiou-wc-ord-print-statuses/README.md

@@ -0,0 +1,406 @@
+# QDR — Studiou WC Order Print Statuses
+
+WordPress plugin that extends WooCommerce with a dedicated workflow for managing print orders sent to third-party print providers. It adds custom order statuses, tracking fields, CSV export, protocol-based CSV imports, and search/filter capabilities tailored to a print fulfillment pipeline.
+
+- **Plugin slug / text domain:** `studiou-wc-ord-print-statuses`
+- **Current version:** 1.5.0
+- **Author:** Dalibor Votruba — [quadarax.com](https://www.quadarax.com)
+- **License:** GPL v2 or later
+
+---
+
+## Requirements
+
+| Component   | Minimum | Tested up to |
+|-------------|---------|--------------|
+| WordPress   | 5.8     | 6.9.4        |
+| WooCommerce | 3.0     | 10.7.0       |
+| PHP         | 7.2     | —            |
+
+Compatible with WooCommerce **High-Performance Order Storage (HPOS)** — the plugin declares `custom_order_tables` compatibility and queries the HPOS `wc_orders` table directly.
+
+---
+
+## Installation
+
+1. Copy the `studiou-wc-ord-print-statuses` folder into `wp-content/plugins/`.
+2. Make sure WooCommerce is installed and active.
+3. Activate the plugin from **Plugins** in the WordPress admin.
+
+No additional setup is required — custom statuses, fields, columns, and bulk actions are registered on activation.
+
+---
+
+## Features
+
+### 1. Custom order statuses
+
+Two new statuses are inserted in the WooCommerce status pipeline, right after `processing`:
+
+| Slug          | Label                | Purpose                                         |
+|---------------|----------------------|-------------------------------------------------|
+| `wc-to-print` | Prepare to Printing  | Order is queued and exported to the print shop. |
+| `wc-in-print` | In Printing          | Print shop has accepted the order.              |
+
+Status transitions automatically stamp the related metadata field (`to_print_date` / `in_print_date`) with the current server time.
+
+### 2. Print Information fields on the order edit page
+
+A new **Print Information** panel is added to the order details screen with four editable fields:
+
+- **Prepare To Print Date** (`to_print_date`)
+- **In Print Date** (`in_print_date`)
+- **External Order Number** (`external_ref_ord_no`)
+- **External Order Date** (`external_ref_ord_date`) — populated by InPrint protocol imports.
+- **External Order Delivered** (`external_ref_ord_delivered`)
+
+### 3. Bulk actions on the Orders list
+
+Available in the **Bulk actions** dropdown on **WooCommerce → Orders**:
+
+- **Prepare to Printing Export** — runs the print-shop export SQL across the selected orders, streams the result as a CSV download, and moves every selected order to `wc-to-print`.
+- **Set Status to Prepare to Printing** — moves selected orders to `wc-to-print` and stamps `to_print_date`.
+- **Set Status to In Printing** — moves selected orders to `wc-in-print` and stamps `in_print_date`.
+
+### 4. Extra columns in the Orders list
+
+Three columns are appended after **Total**:
+
+- **To Print Date**
+- **In Print Date**
+- **External Order Number**
+
+### 5. Search by External Order Number
+
+The standard WooCommerce **Search orders** input also matches the `external_ref_ord_no` meta value — no separate input is required. Works under both legacy CPT storage and HPOS (`woocommerce_shop_order_search_fields` and `woocommerce_order_table_search_query_meta_keys` filters).
+
+### 6. Per-order-item print status (1.5.0+)
+
+Each product line item on an order carries its own print status independently of the order's status:
+
+| Status         | Meaning                                                   |
+|----------------|------------------------------------------------------------|
+| `pending-print` | Initial. Waiting for the print provider.                  |
+| `in-print`      | Currently being printed.                                  |
+| `done-print`    | Printed.                                                   |
+| `skip-print`    | Skipped — won't be printed (operator decision, exclusion). |
+
+The status is editable per item from the **Items** table on the order edit screen via a `<select>` in the new **Print Status** column. Non-product line items (shipping, fees, coupons, refunds) don't carry a print status and show `—`.
+
+**Coupling rules between order status and item statuses** (one-way, order → items):
+
+| Order transition         | Effect on product items                                                                       |
+|---------------------------|-----------------------------------------------------------------------------------------------|
+| → `wc-in-print`           | `pending-print` items advance to `in-print`. Other statuses untouched.                        |
+| → `wc-completed`          | **Gated, not propagating.** Blocked if any product item is still `pending-print` or `in-print`. |
+| All other order transitions | No automatic item change.                                                                   |
+
+Item-level status changes never propagate back to the order. The operator drives the order status independently — the only constraint is the completion guard.
+
+Protocol imports interact with this feature:
+
+- **InPrint Protocol** sets the order to `wc-in-print`; items propagate automatically via the rule above (no special code in the importer).
+- **Delivered Protocol** advances every still-`pending-print`/`in-print` item to `done-print` *before* flipping the order to `wc-completed`, so the completion guard is satisfied. The summary notice tells the operator how many items were auto-advanced.
+
+The CSV export adds a `prod_print_status` column reflecting each line item's current status.
+
+### 7. Protocol imports
+
+A **WooCommerce → Import Protocols** submenu exposes two CSV upload forms:
+
+- **Import InPrint Protocol** — for every row in the file, the matching order is set to `wc-in-print` and the external reference number / date are saved.
+- **Import Delivered Protocol** — for every row in the file, the matching order is set to `wc-completed` and the delivery timestamp is recorded.
+
+Both importers require the `manage_woocommerce` capability and are protected by WordPress nonces.
+
+---
+
+## CSV formats
+
+> **About `order_no`:** the `order_no` column written to the export and read by both imports is the **WooCommerce internal order ID** (`wp_wc_orders.id` under HPOS, `wp_posts.ID` under legacy CPT). It is **not** the user-facing order number produced by Sequential Order Numbers and similar plugins. If you install a custom-order-number plugin, the protocols still operate on the underlying order IDs — the print shop must echo back whatever value was sent in the export.
+
+### Export — Prepare to Printing
+
+Generated file: `prepare_to_printing_export.csv` (UTF-8 with BOM — opens correctly in Excel on Windows).
+
+Columns (in order):
+
+```
+order_no, prod_cat, prod_name, prod_var, prod_var_type, prod_img_url, qty, prod_print_status, email
+```
+
+`prod_print_status` (added in 1.5.0) carries the per-line-item print status — one of `pending-print`, `in-print`, `done-print`, or `skip-print`.
+
+- `prod_var_type` is sourced from the variation's `attribute_pa_format` postmeta, resolved against the `wp_terms` table.
+- `prod_cat` is a comma-separated list (deduplicated) when a product belongs to multiple `product_cat` terms — the row itself is not duplicated.
+- The file is generated **before** any status change. If the underlying SQL returns no rows the file is not produced and statuses are still advanced (per spec), with an admin notice surfacing the situation.
+
+Headers are normalised on read: column names are lowercased and internal whitespace is collapsed to underscore. Both the original spec casing (`Order No`, `ExternalOrder`, `ExternalOrderDate`) and the documented lowercase form (`order_no`, `externalorder`, `externalorderdate`) work. Rows are also **deduplicated by `order_no`** before processing.
+
+### Import — InPrint Protocol
+
+Required headers (any of the accepted casings):
+
+```
+order_no, externalorder, externalorderdate
+```
+
+Effect per row: order → status `wc-in-print`, sets `in_print_date` (now), `external_ref_ord_no`, `external_ref_ord_date` (parsed from the CSV). Orders in a terminal status (`completed`, `cancelled`, `refunded`, `failed`) are skipped with a per-row error notice.
+
+### Import — Delivered Protocol
+
+Required header:
+
+```
+order_no
+```
+
+Effect per row: order → status `wc-completed`, sets `external_ref_ord_delivered` (now).
+
+---
+
+## Architecture
+
+The plugin follows a manager-based pattern. The main bootstrapper (`studiou-wc-ord-print-statuses.php`) wires up specialised classes, each owning one slice of behaviour:
+
+| Class                     | File                                       | Responsibility                                   |
+|---------------------------|--------------------------------------------|--------------------------------------------------|
+| `Order_Status_Manager`    | `includes/class-order-status-manager.php`  | Register custom statuses, handle transitions.    |
+| `Order_Fields_Manager`    | `includes/class-order-fields-manager.php`  | Render and persist Print Information fields.    |
+| `Bulk_Actions_Manager`    | `includes/class-bulk-actions-manager.php`  | Register and dispatch bulk actions.             |
+| `Custom_Columns_Manager`  | `includes/class-custom-columns-manager.php`| Inject extra columns into the Orders list.      |
+| `Import_Manager`          | `includes/class-import-manager.php`        | Admin UI + handlers for protocol CSV imports.   |
+| `Order_Search_Manager`    | `includes/class-order-search-manager.php`  | Extend order search to external reference no.    |
+| `Studiou_DB_Manager`      | `includes/class-db-manager.php`            | All SQL and protocol-processing logic.           |
+| `UtilsLog`                | `includes/utils-log.php`                   | Lightweight logging helper (uses `WP_DEBUG`).   |
+
+The export query in `Studiou_DB_Manager::get_orders_for_printing()` joins `wc_orders`, `wc_order_product_lookup`, `posts` (products + variations), `postmeta` (variation attribute + thumbnail), `terms`, `term_taxonomy`, and `term_relationships` to assemble the print-shop payload in a single round-trip.
+
+---
+
+## Internationalization
+
+- Text domain: `studiou-wc-ord-print-statuses`
+- Translation files: `languages/`
+- Bundled locales: **Czech (`cs_CZ`)** — see `docs/translations.txt` for the EN→CS reference list.
+
+Use the standard WordPress functions (`__()`, `_e()`, `_x()`, `_n_noop()`) for any new strings.
+
+---
+
+## Logging & user notices
+
+- `UtilsLog::log($message)` writes to the WordPress error log when `WP_DEBUG` is enabled (guarded as `defined('WP_DEBUG') && WP_DEBUG` — works for both boolean and integer truthy values). With `WP_DEBUG_LOG` on, log output ends up in `wp-content/debug.log`.
+- `UtilsLog::message($message, $type)` queues a dismissible WordPress admin notice (`info` / `success` / `warning` / `error`) for the current user. Notices are stored in a per-user transient (60-second TTL) so they survive the redirect that follows bulk actions and imports. Used to report success counts, skipped orders, malformed CSV rows, etc.
+
+---
+
+## Project layout
+
+```
+studiou-wc-ord-print-statuses/
+├── studiou-wc-ord-print-statuses.php   # Plugin bootstrap (header + main class)
+├── includes/
+│   ├── class-db-manager.php
+│   ├── class-order-status-manager.php
+│   ├── class-order-fields-manager.php
+│   ├── class-order-search-manager.php
+│   ├── class-bulk-actions-manager.php
+│   ├── class-custom-columns-manager.php
+│   ├── class-import-manager.php
+│   └── utils-log.php
+├── languages/                          # .po / .mo translation files
+├── docs/                               # Project documentation (see below)
+├── README.md                           # This file
+└── CLAUDE.md                           # Guidance for Claude Code sessions
+```
+
+---
+
+## Documentation
+
+Reference documents live under [`docs/`](docs/):
+
+- [`docs/readme.md`](docs/readme.md) — Previous user-facing readme (kept for history).
+- [`docs/instructions.txt`](docs/instructions.txt) — Original feature specification used to scaffold the plugin.
+- [`docs/translations.txt`](docs/translations.txt) — EN → CS translation reference.
+- [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md) — Analytical review of v1.3.2 (all findings addressed in 1.4.0).
+- [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) — Analytical review of v1.4.0 (all in-scope findings addressed in 1.4.1).
+- [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) — Analytical review of v1.4.1 (all in-scope findings addressed in 1.4.2).
+- [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) — Fresh-eye analytical review of v1.4.2 (all in-scope findings addressed in 1.4.3).
+- [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) — Fresh-eye analytical review of v1.4.3 (all in-scope findings addressed in 1.4.4).
+- [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) — Fresh-eye analytical review of v1.4.4 (mediums + lows addressed in 1.4.5; carried-over deferrals documented).
+
+Project-internal guidance for AI-assisted development is documented in [`CLAUDE.md`](CLAUDE.md).
+
+---
+
+## Known limitations
+
+- **HPOS search filter name** (`woocommerce_order_table_search_query_meta_keys`) has not been live-verified against WC 10.7.0. The legacy CPT filter is solid; the HPOS branch should be smoke-tested on the production site before relying on it.
+- **Variable products only** — the export query joins variation tables and the CSV columns include `prod_var` / `prod_var_type`. Simple (non-variable) products appear in the export as of 1.4.2 (via `LEFT JOIN`), but their variation fields are blank.
+- **WC Analytics dependency** — the export reads from `wc_order_product_lookup`, the WooCommerce Analytics lookup table. If WC Analytics is disabled or the table is missing, the export emits an admin notice and skips.
+- **Block-based order edit screen** — the Print Information panel is wired to the classic order edit screen via `woocommerce_admin_order_data_after_order_details`. Behaviour on the WC 10.x block-based order edit screen has not been verified.
+- **Multisite** — no `Network:` plugin header is set; activate per blog.
+- **No uninstall cleanup** — meta keys (`to_print_date`, `external_ref_ord_no`, etc.) remain in the database after plugin removal.
+- **Custom order numbers** — the protocols operate on WC internal order IDs (see the note above the Export section). Sequential Order Numbers / custom-order-number plugins are not integrated.
+- **The `attribute_pa_format` taxonomy is hardcoded** in the export SQL. Stores using a different attribute name will get a blank `prod_var_type`.
+- **MySQL `group_concat_max_len`** is raised to 64 KB via `SET SESSION` per export — connection-scoped, harmless but worth knowing if you maintain custom queries on the same connection.
+- **Excel CS-locale CSV separator** — the export uses `,` as the field separator. Czech Excel defaults to `;` as the list separator and may render the file as one column. Workaround: open with the Text Import wizard or set Windows regional list-separator to `,`. Other consumers (LibreOffice Calc, Numbers, programmatic CSV parsers) handle the comma natively.
+- **Trashed/draft products in historical orders** still appear in the export. Order line items reference product IDs; the export joins by ID without filtering on `post_status`. The intended behaviour: show what was sold, even if the product has since been removed from the catalogue.
+- **MySQL collation mismatch on the variation-attribute slug join** (`terms.slug = postmeta.meta_value`) is theoretically possible on databases that have been migrated across MySQL versions with mixed per-table collations. On standard `utf8mb4_unicode_ci` installs the join works fine. If `ERROR 1267 (Illegal mix of collations)` ever surfaces on the bulk export, the fix is to add an explicit `COLLATE` clause to the join condition.
+
+## Changelog
+
+### 1.5.0 — 2026-05-12
+
+**New feature: per-order-item print status.** Each product line item now carries its own print status independent of the order's status, with explicit coupling rules between the two.
+
+- **Added:** four item-level statuses — `pending-print` (initial), `in-print`, `done-print`, `skip-print`. Stored as `_print_status` meta on each `WC_Order_Item_Product`. Missing meta defaults to `pending-print` (lazy — no migration needed for existing orders).
+- **Added:** "Print Status" column in the order edit screen's Items table. Per-row `<select>` lets the operator change item status manually. Non-product items show `—`.
+- **Added:** Order → item propagation on `wc-in-print` transitions. The order being flipped to "In Printing" (via manual admin change, bulk action, or InPrint Protocol import) advances all `pending-print` items to `in-print`. `done-print` and `skip-print` items are untouched.
+- **Added:** Completion guard on `wc-completed` transitions. The order cannot complete while any product line item is still `pending-print` or `in-print`. Blocked transitions revert with an admin notice listing the count of unready items. Existing already-completed orders are unaffected.
+- **Added:** Order activity notes for every item-status change ("Item #5 (Product A): Pending Print → Done Print.") and for batch auto-advances ("3 line items advanced to In Print by order-status change.").
+- **Changed:** `Studiou_DB_Manager::import_delivered_protocol()` now calls `advance_remaining_to_done()` before flipping the order to `wc-completed`. Without this the completion guard would block every Delivered Protocol import. The import summary surfaces the total items advanced.
+- **Changed:** Export CSV adds `prod_print_status` column. Existing print-shop tooling that imports the CSV needs to add (or ignore) this column.
+- **Changed:** Bulk action "Set Status to In Printing" now also propagates items (via the new listener — no Bulk_Actions_Manager code change). Bulk action "Set Status to Prepare to Printing" remains order-only.
+- **Doc:** new manager class added to the manager-pattern in CLAUDE.md; planning doc at [`docs/feature-order-item-status-analysis.md`](docs/feature-order-item-status-analysis.md).
+
+> **Known limitations carried into 1.5.0:** the new column renders on the classic order edit screen only — WC 10.x's block-based order edit screen (still feature-flagged) is not supported. Tracked as a phase-2 follow-up.
+
+### 1.4.5 — 2026-05-12
+
+Defensive-hardening release addressing the medium and low findings in [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md):
+
+- **Fixed:** `Studiou_DB_Manager::normalise_csv_date()` fast path now validates date and time ranges with `checkdate()` + hour/minute/second bounds. Structurally-valid but semantically-bogus inputs like `"2026-13-45T25:99"` fall through to the `DateTimeImmutable` fallback, which throws → caught → empty string returned. Mirrors the validation pattern already in `Order_Fields_Manager::datetime_local_to_mysql`.
+- **Fixed:** `Import_Manager::emit_import_summary()` now surfaces an info notice ("The CSV file contained no actionable rows (empty order_no column).") when both `$updated` and `$errors` are zero — closes the silent-import case where every CSV row had an empty `order_no` column.
+- **Fixed:** `Studiou_DB_Manager::csv_escape()` now `ltrim`s before checking the first character. Cells like `" =CMD"` — which some spreadsheet apps strip and evaluate — are no longer slipping past the formula-injection defence.
+- **Fixed:** `Studiou_DB_Manager::parse_csv()` rejects CSVs whose normalised headers collide (e.g., two columns both ending up as `order_no` after whitespace/case normalisation). Previously `array_combine` silently overwrote, losing one column's data. Now logs and returns empty so the downstream import bails with the existing "could not be parsed" notice.
+- **Changed:** `bulk_set_print_status` order-note selection lifted from a binary ternary to an explicit `array(slug => note)` map. Adding a third status in the future no longer risks inheriting the wrong note via the else branch.
+- **Doc:** [`docs/revise-1.4.4.md`](docs/revise-1.4.4.md) updated with per-item status annotations. README "Known limitations" gains an entry for the theoretical MySQL collation mismatch on the slug join (§3.4 of the review).
+
+> **Deferred** (low-impact, carried over): main plugin instance lifecycle (architectural, no functional impact), `woocommerce_missing_notice` mid-request re-fire (edge case), collation mismatch (theoretical, no production occurrence).
+
+### 1.4.4 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md):
+
+- **Fixed (high):** `Studiou_DB_Manager::normalise_csv_date()` was shifting timezone-less CSV dates by the WP timezone offset. WordPress sets the PHP timezone to UTC very early, so `strtotime("2026-05-12 14:30")` returned a UTC timestamp; `wp_date()` then formatted it in WP-local, producing a Prague-shifted "2026-05-12 16:30:00". Now uses pure-regex reformat for ISO-like inputs (no shift) and `DateTimeImmutable(..., wp_timezone())->setTimezone(wp_timezone())` for the fallback path (handles explicit-timezone inputs correctly). **No remaining `strtotime` calls in `includes/`** — the recurring timezone-bug pattern is now structurally eliminated.
+- **Fixed:** `Studiou_DB_Manager::get_orders_for_printing()` now pre-checks the HPOS `wc_orders` table parallel to the existing `wc_order_product_lookup` check. If HPOS is disabled the export bails with a clear admin notice ("Export requires WooCommerce HPOS … Enable High-Performance Order Storage …") instead of failing with a cryptic SQL error.
+- **Fixed:** `bulk_set_print_status` now passes an explanatory note to `update_status` — "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." — matching the audit-trail pattern the import paths use.
+- **Changed:** `Custom_Columns_Manager` uses the `WC_Order` object directly when WC hands one to the column-content callback (HPOS path); under legacy CPT it memoises by ID per request so the same order isn't re-resolved across the three custom columns. Avoids 100–150 `wc_get_order` calls per 50-orders-per-page list.
+- **Changed:** `Import_Manager::emit_import_summary()` suppresses the "0 orders updated successfully" notice when no rows changed — the per-error notice stands on its own. Matches the `notify_moved_count` pattern.
+- **Changed:** `call_user_func($cfg['handler'], $csv_data)` → direct callable invocation. Tighter and slightly faster.
+- **Changed:** The `before_woocommerce_init` HPOS-compat declaration is now a named function (`studiou_wc_ord_print_statuses_declare_hpos_compat`). Can be `remove_action`-ed from outside.
+- **Doc:** [`docs/revise-1.4.3.md`](docs/revise-1.4.3.md) updated with per-item status annotations.
+
+### 1.4.3 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md):
+
+- **Fixed (high):** `import_delivered_protocol` was missing the terminal-status guard that `import_inprint_protocol` had. A `cancelled`, `refunded`, or `failed` order in the delivered CSV was silently reactivated to `completed`. Now skipped with a per-row error notice. `completed` orders are still allowed through (re-running the same protocol is a benign no-op transition).
+- **Fixed (high):** Import Protocols screen required `manage_woocommerce` (Administrator-only) while bulk actions only required `edit_shop_orders` (Shop Manager). Shop Managers can now run protocol imports — the two paths share the same capability requirement.
+- **Fixed:** `Studiou_DB_Manager::table_exists()` wraps the table name in `$wpdb->esc_like()`. The previous `SHOW TABLES LIKE %s` left the conventional `_` in `$wpdb->prefix` as a SQL wildcard; the post-query equality comparison mitigated false positives but the safety relied on the wrong layer.
+- **Fixed:** CSV export now defuses Excel/LibreOffice/Sheets formula injection — any cell beginning with `=`, `+`, `-`, `@`, `\t`, or `\r` is prefixed with a single quote. OWASP-recommended pattern; the leading quote is hidden by spreadsheet apps but disables formula evaluation.
+- **Fixed:** `Order_Fields_Manager::datetime_local_to_mysql()` now validates date and time ranges via `checkdate()` plus hour/minute/second bounds. Direct POST or DevTools-edited form values like `2026-13-45T25:99` no longer slip through to be stored as bogus MySQL datetimes.
+- **Fixed:** `qty` is now formatted in PHP — `SUM(DECIMAL)` returns `"2.0000"` from MySQL; the post-processing step normalises whole numbers to `"2"` and trims trailing zeros from decimals.
+- **Fixed:** Both import handlers now pass an explanatory note to `update_status` ("Marked In Printing by InPrint Protocol import." / "Marked completed by Delivered Protocol import."). The order activity feed reflects how the transition was triggered.
+- **Fixed:** Both import handlers now log per-row to `UtilsLog::log` — symmetric with the bulk-action path. Helps debug failed protocol imports.
+- **Fixed:** `Import_Manager::emit_import_summary()` deduplicates repeated error strings. A CSV with 100 malformed rows shows "100 errors occurred: Invalid row format in CSV." once, not 100 times concatenated.
+- **Changed:** `UtilsLog::render_notices()` switched from `wp_kses_post()` to `esc_html()` — current notice messages are plain text, the tighter escape narrows the trust boundary at the API edge.
+- **Removed:** `Order_Status_Manager::set_order_processing_status()` and its `woocommerce_payment_complete` hook. On modern WC the handler was effectively dead code — `WC_Order::payment_complete()` sets the order to `processing` (or `completed` for downloadable-only) *before* firing the action, so our handler was either a no-op same-state update or a deliberate skip. The corresponding translation string was removed too.
+- **Doc:** README "Known limitations" gains two entries (Excel CS-locale CSV separator, trashed/draft products in historical orders). CLAUDE.md picks up the 1.4.3 changes.
+- **Doc:** [`docs/revise-1.4.2.md`](docs/revise-1.4.2.md) updated with per-item status annotations.
+
+> **Deferred** (carried over from earlier reviews): live HPOS search-filter verification, block-based order edit screen, `Network:` plugin header, `uninstall.php`, custom-order-number plugin integration.
+
+### 1.4.2 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md):
+
+- **Fixed (high):** Custom_Columns_Manager date columns ("To Print Date", "In Print Date") were applying the WP timezone offset twice — `strtotime()` interpreted the stored value in PHP server timezone, then `date_i18n()` formatted in WP timezone. On UTC-server / non-UTC-WP setups (e.g. UTC server + Europe/Prague site) the displayed time drifted by 1–2 hours. Now parsed explicitly as WP-local via `DateTimeImmutable(..., wp_timezone())` and formatted with `wp_date()`.
+- **Fixed (high):** Export SQL joined `wp_terms.name` against the variation's `attribute_pa_format` postmeta, but WC stores the **slug** there, not the name. The join is now `terms.slug = meta_value`, while `MIN(terms.name)` still returns the human-readable display value.
+- **Fixed (high):** `qty` is now `SUM(order_products.product_qty)` instead of `MIN(...)`. A single order can have multiple line items for the same (product, variation) when the customer added it twice without cart-merge; the previous query silently undercounted in that case.
+- **Fixed:** `JOIN product_variations` was inner — silently excluded orders containing simple (non-variable) products. Now a `LEFT JOIN`; simple products are included with blank `prod_var`/`prod_var_type`.
+- **Fixed:** `prod_img_url` no longer reads `wp_posts.guid` (which WP codex warns is not necessarily a URL). The export now selects `_thumbnail_id` and resolves URLs via `wp_get_attachment_url()` — respects offloaded-media plugins, multisite uploads paths, and `wp_get_attachment_url` filters.
+- **Fixed:** `Studiou_DB_Manager::normalise_csv_date()` now formats with `wp_date()` (WP timezone) instead of `date()` (server timezone), matching `current_time('mysql')` elsewhere.
+- **Added:** Pre-flight check for `wc_order_product_lookup` — if WC Analytics is disabled and the lookup table is missing, the export bails with a clear admin notice instead of failing with a cryptic SQL error.
+- **Changed:** `SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)` — never lowers a higher session-scoped value another piece of code may rely on.
+- **Added:** Server-side `.csv` extension check on protocol uploads (defense in depth on top of the existing `is_uploaded_file()` / `$_FILES[...]['error']` checks).
+- **Fixed:** `Order_Status_Manager::add_custom_order_statuses_to_list()` falls back to appending the custom statuses at the end of the dropdown if `wc-processing` was removed by another plugin.
+- **Changed:** `parse_csv()` drops error-suppression `@fopen` — failures are logged explicitly.
+- **Doc:** README gains a "Known limitations" section and clarifies what `order_no` means in CSVs. CLAUDE.md corrected (the `attribute_pa_format` join uses **slug**, not name).
+- **Doc:** [`docs/revise-1.4.1.md`](docs/revise-1.4.1.md) updated with per-item status annotations.
+
+> **Deferred** (carried over): live HPOS search-filter verification, `Network:` plugin header, `uninstall.php`, block-based order edit screen, custom-order-number plugin integration.
+
+### 1.4.1 — 2026-05-12
+
+Bug-fix release addressing the findings catalogued in [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md):
+
+- **Fixed (critical):** Export SQL is now compatible with MySQL's `ONLY_FULL_GROUP_BY` mode (default in MySQL 5.7+ / MariaDB 10.3+). Non-aggregated columns (`prod_name`, `prod_var`, `prod_var_type`, `prod_img_url`, `qty`, `email`) are wrapped in `MIN()`; each `(product_id, variation_id)` group has exactly one value per column so `MIN()` is value-preserving.
+- **Fixed (critical):** `parse_csv()` now strips a leading UTF-8 BOM. Re-importing a previously exported CSV (which ships with a BOM for Excel-on-Windows compatibility) no longer breaks header parsing.
+- **Fixed (high):** Datetime fields in the Print Information panel now reformat values as pure strings (`YYYY-MM-DD HH:MM:SS` ↔ `YYYY-MM-DDTHH:MM`) instead of running them through `strtotime()`/`date()`. The previous timezone-shift bug (off by 1–2 hours on UTC servers with non-UTC WP timezones) is gone.
+- **Fixed:** `set_order_processing_status()` now records a single order note via `update_status()` instead of also calling `add_order_note()`. The order activity feed no longer duplicates the "payment received" line.
+- **Fixed:** Bulk-action notices are suppressed when zero orders were actually moved — the more informative "skipped because terminal status" warning from the DB manager stands alone.
+- **Fixed:** `output_csv()` now aborts with `wp_die()` (and logs the offender) when `headers_sent()` is true, instead of streaming raw CSV bytes into a half-rendered HTML page.
+- **Fixed:** `fputcsv`/`fgetcsv` now pass an explicit empty-string escape character — no more PHP 8.4 deprecation warnings.
+- **Fixed:** `Order_Fields_Manager::save_custom_order_fields()` now checks `current_user_can('edit_shop_order' / 'edit_shop_orders')` before writing. Defense-in-depth on top of WC's own validation.
+- **Fixed:** `Studiou_DB_Manager::get_orders_for_printing()` raises `group_concat_max_len` to 64 KB before the export query, preventing silent `prod_cat` truncation for products in many categories.
+- **Fixed:** `dedupe_by_order_no()` is now case-insensitive — `ord-12345` and `ORD-12345` collapse to a single row.
+- **Fixed:** `normalise_csv_date()` returns an empty string (and logs) on parse failure instead of persisting the unparseable raw value.
+- **Changed:** `Import_Manager::run_import()` is driven by a single `protocol_dispatch()` map (file field, nonce, DB handler per protocol). Every non-happy-path branch returns explicitly via `redirect_back()` for static-analysis friendliness.
+- **Changed:** `Order_Fields_Manager` rendering and saving are driven from the `datetime_fields()` / `text_fields()` maps. Adding a new field is a single-map-entry change.
+- **Changed:** Notice transient TTL raised from 60 s to 300 s so admin notices survive slow redirects (proxies, HTTPS handshake latency).
+- **Doc:** [`docs/revise-1.4.0.md`](docs/revise-1.4.0.md) updated with per-item status annotations.
+
+> **Deliberately deferred** (carried over from the 1.4.0 review): live verification of the HPOS search filter name (`woocommerce_order_table_search_query_meta_keys`), `Network:` plugin header for multisite, `uninstall.php` cleanup, and behaviour on the WC 10.x block-based order edit screen. None of these were addressed by 1.4.1.
+
+### 1.4.0 — 2026-05-12
+
+- **Tested with:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled).
+
+Resolution of every finding catalogued in [`docs/revise-1.3.2.md`](docs/revise-1.3.2.md). Highlights:
+
+- **Fixed:** Search by External Order Number is now functional. The meta-key mismatch is resolved (`external_ref_ord_no` consistently, no `_` prefix), and the integration uses WooCommerce's own search filters (`woocommerce_shop_order_search_fields` for legacy CPT and `woocommerce_order_table_search_query_meta_keys` for HPOS). No separate UI input — typing the external order number into the standard Search orders box finds the order.
+- **Fixed:** `Order_Status_Manager::handle_status_transitions()` now writes timestamps via `wc_get_order()->update_meta_data()` instead of `update_post_meta()`. Print-date stamps are no longer silently lost under HPOS for non-bulk status transitions.
+- **Fixed:** CSV imports now deduplicate rows by `order_no` (per the original spec) and accept both the spec's title-case headers and the lowercase form documented in the README.
+- **Fixed:** "Prepare to Printing Export" is now atomic — the CSV is generated in memory before any status change. Order status is advanced after a successful CSV build. If the export query returns no rows the user is notified.
+- **Fixed:** Export CSV is now UTF-8 with BOM (`charset=utf-8` header + leading `\xEF\xBB\xBF`). Czech diacritics render correctly in Excel on Windows.
+- **Fixed:** Export SQL no longer multiplies rows when a product belongs to multiple `product_cat` terms — categories are concatenated via `GROUP_CONCAT(DISTINCT …)` inside a `GROUP BY` per line item.
+- **Fixed:** Export SQL now goes through `$wpdb->prepare()` with `%d` placeholders rather than relying on `intval()` for safety.
+- **Fixed:** Late `woocommerce_payment_complete` events no longer demote orders already in `to-print` or `in-print` back to `processing`. The guard extends to all print statuses plus `completed`.
+- **Fixed:** Bulk actions guard with `current_user_can('edit_shop_orders')`. Imports keep their existing `manage_woocommerce` guard.
+- **Fixed:** Bulk actions and protocol imports skip orders in terminal statuses (`completed`, `cancelled`, `refunded`, `failed`) instead of silently reactivating them.
+- **Fixed:** `UtilsLog::log()` guard is now `defined('WP_DEBUG') && WP_DEBUG` (previously `true === WP_DEBUG`, which failed when `WP_DEBUG` was defined as integer `1`).
+- **Fixed:** `UtilsLog::message()` is now implemented as a per-user transient that renders via the `admin_notices` hook. Bulk actions and imports now produce visible user feedback after the redirect.
+- **Fixed:** `parse_csv()` no longer truncates rows at 1000 bytes (passes `0` to `fgetcsv`), validates that the header row is non-empty, normalises header casing, and skips rows whose column count diverges from the header.
+- **Fixed:** Import handlers verify `$_FILES[...]['error']` and `is_uploaded_file()` server-side instead of relying solely on the client-side `accept=".csv"` attribute.
+- **Fixed:** `external_ref_ord_date` is parsed via `strtotime()` and stored in MySQL datetime format; if parsing fails the raw value is retained.
+- **Added:** `external_ref_ord_date` is now rendered in the Print Information panel on the order edit screen.
+- **Added:** `STUDIOU_WC_OPS_VERSION` and `STUDIOU_WC_OPS_FILE` constants for code-side version references.
+- **Changed:** Translation loader and plugin bootstrap now have explicit `plugins_loaded` priorities (5 and 10) so translations are guaranteed to load before any manager instantiates.
+- **Removed:** `// visualized` leftover comments and the dead-code commented body of `UtilsLog::message()`.
+- **Docs:** README architecture/install/usage updated, `CLAUDE.md` updated, `docs/translations.txt` extended with the strings that were missing.
+
+> **Translations:** the `.po` file ships new strings; the `.mo` file must be rebuilt (e.g. via `wp i18n make-mo languages/` or Poedit) before the new Czech wording is visible. This release does not regenerate the binary.
+
+### 1.3.2 — 2025-10-18
+- **Fixed:** `prod_var_type` column was empty in `prepare_to_printing_export.csv`. Variation attribute is now resolved through `postmeta.attribute_pa_format` joined against `wp_terms.name`.
+- **Fixed:** HPOS incompatibility warning. Plugin now explicitly declares `custom_order_tables` compatibility.
+- **Tested:** WordPress 6.8.3, WooCommerce 10.2.2 with HPOS enabled.
+
+### 1.3.1
+- Previous stable release.
+
+---
+
+## Support
+
+For bug reports, feature requests or commercial support, contact **[quadarax.com](https://www.quadarax.com)**.
+
+---
+
+## License
+
+Released under the **GNU General Public License v2.0 or later** — see [https://www.gnu.org/licenses/gpl-2.0.html](https://www.gnu.org/licenses/gpl-2.0.html).

+ 397 - 0
studiou-wc-ord-print-statuses/docs/feature-order-item-status-analysis.md

@@ -0,0 +1,397 @@
+# Feature Analysis — Per-Order-Item Print Status
+
+**Date:** 2026-05-12 (revised after dropping the "independent statuses" requirement)
+**Target plugin version after implementation:** 1.5.0 (minor bump — new feature, additive, non-breaking).
+**Status:** Analysis / planning document. No code has been written yet.
+
+---
+
+## 1. Requirements as received
+
+> 1. Add new status for order item called `order-item-status`:
+>    - `pending-print` — order item is waiting for printing process — *initial*
+>    - `in-print` — order item is currently processing for print
+>    - `done-print` — order item was printed
+>    - `skip-print` — order item is skipped by some custom / system reason
+> 2. Add to order detail to order item possibility (combo box) to change order-item-status manually.
+> 3. When Order status is going to change to `done`, all order items must be `!= pending-print` and `!= in-print`.
+
+> *(Previous requirement #3 — "Order status and order-item-status are independent" — was withdrawn by the stakeholder; this analysis assumes order ↔ item coupling is allowed and beneficial. See §3 for the proposed coupling rules.)*
+
+---
+
+## 2. Logical revisions
+
+### 2.1 "When order status is going to change to `done`" — clarify what "done" means
+
+WooCommerce has no `wc-done` status. The plugin already adds `wc-to-print` and `wc-in-print`. The natural interpretation is the WC built-in `wc-completed`.
+
+**Proposed revision:** *"When the order status is going to change to `wc-completed`"*.
+
+### 2.2 What about non-product line items?
+
+Orders contain product items but also fee items, shipping items, coupon items, tax items, and refund items. None of these are printed.
+
+**Proposed revision:** the completion guard inspects only **product line items** (`WC_Order_Item_Product`). Other item types are not assigned a print status and never block completion.
+
+### 2.3 "Initial" value of `pending-print` — when is it set?
+
+The spec implies new items default to `pending-print`. Two strategies:
+
+- **Lazy (recommended):** the per-item meta is not written when the order is created. The `get_status()` helper returns `'pending-print'` when the meta is missing. Migration of existing orders is unnecessary.
+- **Eager:** write `pending-print` to every new item via `woocommerce_new_order_item`. Existing orders need a one-time backfill migration.
+
+**Proposed revision:** lazy default. Missing meta = `pending-print`. This avoids touching every existing order at activation time.
+
+---
+
+## 3. Coupling rules between order status and item statuses
+
+With the "independence" constraint dropped, the design space opens up. The cleanest model is **one-way coupling, order → items**: changing an order status can propagate to items; changing an item status never propagates back to the order. Within that, we need to decide *which* order-status transitions propagate and how.
+
+The proposed rules:
+
+| Order status transition | Effect on product items |
+|--------------------------|-------------------------|
+| → `wc-to-print` (Prepare to Printing) | No automatic change. Items typically already `pending-print` (lazy default). |
+| → `wc-in-print` (In Printing) | **Items in `pending-print` advance to `in-print`.** `done-print` / `skip-print` items are untouched. |
+| → `wc-completed` | **Gated, not propagating** — the transition is *blocked* if any product item is still `pending-print` or `in-print`. Operator (or protocol import) must advance items first. |
+| → `wc-cancelled` / `wc-refunded` / `wc-failed` | No automatic change. Items stay where they are (historical record preserved). |
+| → `wc-processing` / `wc-pending` / `wc-on-hold` | No automatic change. |
+| Item status changed manually | Never propagates to order. The operator drives the order status separately. |
+
+Two distinct kinds of coupling are at play:
+
+- **Propagation** (`→ in-print`): the order transition *drives* item state. The listener that handles `woocommerce_order_status_changed` runs the propagation.
+- **Guard** (`→ completed`): the order transition *requires* items to already be in the right state. If not, the transition is reverted. The same listener implements the guard.
+
+Both behaviours live in a single hook on `woocommerce_order_status_changed`. The protocol imports become natural consumers of these rules:
+
+- **InPrint Protocol** calls `$order->update_status('in-print', ...)`. The propagation rule advances pending items to `in-print` automatically. No special-case code in the protocol importer.
+- **Delivered Protocol** calls `$order->update_status('completed', ...)`. The guard would block this if items are still `pending-print` or `in-print`. The protocol importer must therefore call `advance_remaining_to_done($order)` *before* the status flip.
+
+---
+
+## 4. Open questions for stakeholder decision
+
+Before implementation begins, the following decisions should be confirmed:
+
+1. **Propagation on `→ in-print`** (§3): confirm "advance `pending-print` items to `in-print`" is the desired behaviour for manual order-status changes from the admin dropdown, bulk actions, *and* the InPrint Protocol import. Alternative: leave items untouched, require explicit per-item operations.
+2. **Audit trail:** should item-status changes write an entry to the order's activity feed? Recommendation: yes, with a brief note like *"Item #5 (Product Name): Pending Print → Done Print."*
+3. **`skip-print` reason:** is there a free-text "reason" field, or just the status? Recommendation: status only in v1.5.0; reason field as a future enhancement.
+4. **Bulk per-item operations across orders:** is there a need? Recommendation: not in v1.5.0 — single-order manual control plus protocol/propagation auto-advance covers the workflow.
+5. **CSV-export filter:** should "Prepare to Printing Export" filter to only `pending-print` items, or include everything? Recommendation: include everything, add `prod_print_status` column. Filtering can happen downstream.
+6. **Backwards-compatible behaviour for existing completed orders:** the completion guard applies only to *new* transitions to `wc-completed`. Already-completed orders are unaffected even if their items are `pending-print` (lazy default). Confirm this is acceptable.
+
+---
+
+## 5. Proposed final specification
+
+After applying §2's revisions and §3's coupling rules:
+
+1. Each order item (specifically each `WC_Order_Item_Product`) has a print status with one of four values, persisted to per-item meta key `_print_status`. Missing meta defaults to `pending-print`.
+2. The order detail screen (classic order edit) adds a **"Print Status"** column to the line-items table with a `<select>` per product row. Saving the order persists changes.
+3. **Order → item propagation:**
+   - Transition into `wc-in-print` advances every `pending-print` item to `in-print`. `done-print` and `skip-print` items are untouched.
+   - All other order-status transitions leave item statuses unchanged.
+4. **Completion guard:** transition into `wc-completed` is blocked if any product item is `pending-print` or `in-print`. Blocked transition reverts to the previous status with an admin notice listing the blocking items.
+5. **Protocol imports:**
+   - **InPrint Protocol** sets order to `wc-in-print`; items auto-propagate per rule (3a). No explicit per-item code in the importer.
+   - **Delivered Protocol** explicitly advances every `pending-print` / `in-print` item to `done-print` before setting the order to `wc-completed`. The guard is satisfied; the operator gets one summary notice.
+6. **Item-status manual changes** never propagate to the order. Operator drives the order status independently.
+7. The CSV export from "Prepare to Printing Export" gains a `prod_print_status` column reflecting each item's current status.
+8. Item-status changes (manual or propagated) generate an order note: *"Item #ID (Product name): Old Status → New Status."*
+
+---
+
+## 6. Data model
+
+### 6.1 Storage
+
+- **Meta key:** `_print_status` (underscore prefix per WC convention for internal meta).
+- **Storage layer:** `WC_Order_Item::update_meta_data()` / `get_meta()`.
+- **Underlying table:** `{$wpdb->prefix}woocommerce_order_itemmeta`. This table exists under both HPOS and legacy CPT — WC uses the same item-meta storage in both modes.
+- **No database migration required** (lazy default).
+
+### 6.2 Constants
+
+```php
+class Order_Item_Status_Manager {
+    const STATUS_PENDING = 'pending-print';
+    const STATUS_IN      = 'in-print';
+    const STATUS_DONE    = 'done-print';
+    const STATUS_SKIP    = 'skip-print';
+
+    const STATUSES_THAT_SATISFY_COMPLETION = array(
+        self::STATUS_DONE,
+        self::STATUS_SKIP,
+    );
+
+    public static function labels() {
+        return array(
+            self::STATUS_PENDING => __('Pending Print', 'studiou-wc-ord-print-statuses'),
+            self::STATUS_IN      => __('In Print',      'studiou-wc-ord-print-statuses'),
+            self::STATUS_DONE    => __('Done Print',    'studiou-wc-ord-print-statuses'),
+            self::STATUS_SKIP    => __('Skip Print',    'studiou-wc-ord-print-statuses'),
+        );
+    }
+}
+```
+
+### 6.3 API surface
+
+A new `Order_Item_Status_Manager` class exposes:
+
+| Method | Purpose |
+|--------|---------|
+| `get_status(WC_Order_Item_Product $item): string` | Returns the item's print status; defaults to `STATUS_PENDING` when the meta is missing. |
+| `set_status(WC_Order_Item_Product $item, string $status, bool $audit = true): bool` | Validates the status, writes meta, optionally appends an order note. |
+| `valid_statuses(): array` | Returns the four constants. |
+| `is_order_ready_for_completion(WC_Order $order): array{ready:bool, blocking:WC_Order_Item_Product[]}` | Used by the completion guard and admin UI. |
+| `advance_pending_to_in(WC_Order $order): int` | Used by the propagation listener on `→ in-print`. Returns count advanced. |
+| `advance_remaining_to_done(WC_Order $order): int` | Used by `Delivered Protocol` import before completing. Returns count advanced. |
+
+---
+
+## 7. Files & hooks
+
+### 7.1 New file: `includes/class-order-item-status-manager.php`
+
+Hooks registered in constructor:
+
+| Hook | Purpose |
+|------|---------|
+| `woocommerce_admin_order_item_headers` | Inject the "Print Status" column header. |
+| `woocommerce_admin_order_item_values` | Render the `<select>` per line item. |
+| `woocommerce_process_shop_order_meta` | Read `$_POST['print_status'][$item_id]`, persist via `set_status()`. Runs after WC's own item processing. |
+| `woocommerce_order_status_changed` (priority 5) | Combined propagation + completion guard (see §8). |
+
+### 7.2 Modified: `includes/class-db-manager.php`
+
+- `import_inprint_protocol()`: no functional change — propagation happens automatically via the listener when `$order->update_status('in-print', ...)` fires. Optionally surface the advanced count in the admin notice: *"3 line items advanced to In Print."*
+- `import_delivered_protocol()`: **before** calling `$order->update_status('completed', ...)`, call `Order_Item_Status_Manager::advance_remaining_to_done($order)` so the guard is satisfied. Surface the advanced count in the import-summary notice.
+- `get_orders_for_printing()`: extend the SELECT to surface item-level print status. Join `{$wpdb->prefix}woocommerce_order_items` (the item row) and `{$wpdb->prefix}woocommerce_order_itemmeta` (the `_print_status` meta) on `order_item_id`. Add `prod_print_status` to the CSV output. Note: the existing query groups by `(order, product, variation)` — multiple line items in one group would need a deterministic pick (e.g., `MIN()` again, or join via `order_products.order_item_id` if the lookup table exposes it).
+
+### 7.3 Modified: `studiou-wc-ord-print-statuses.php`
+
+Add the new manager to `load_dependencies()` and `initialize_managers()`, identical to the existing pattern. Bump `STUDIOU_WC_OPS_VERSION` to `1.5.0`.
+
+### 7.4 Modified: `includes/class-bulk-actions-manager.php`
+
+No code change. Bulk actions `"Set Status to Prepare to Printing"` and `"Set Status to In Printing"` already call `$order->update_status(...)` — the propagation listener handles items automatically when the transition is to `in-print`. The "Prepare to Printing" path doesn't propagate (per §3).
+
+### 7.5 Modified: `README.md`, `CLAUDE.md`, `docs/translations.txt`, `languages/studiou-wc-ord-print-statuses-cs_CZ.po`
+
+- README: new "Order item statuses" feature section + 1.5.0 changelog entry + coupling rules table from §3.
+- CLAUDE.md: extend the manager-pattern list with the new manager + Recent Changes entry.
+- Translations: add the four status labels, "Print Status" column header, guard error message, audit-note format, and the protocol auto-advance summary strings.
+
+---
+
+## 8. Combined propagation + completion guard
+
+A single listener on `woocommerce_order_status_changed` handles both behaviours. Pseudocode:
+
+```php
+public function on_order_status_changed($order_id, $from, $to, $order) {
+    // Re-entrancy guard: a guard-revert below fires this hook again. Skip
+    // the recursive invocation.
+    if (self::$inside_self_revert) {
+        return;
+    }
+
+    if ($to === 'in-print') {
+        $advanced = $this->advance_pending_to_in($order);
+        if ($advanced > 0) {
+            UtilsLog::log("Order #{$order_id}: advanced {$advanced} item(s) to in-print on order → in-print");
+        }
+        return;
+    }
+
+    if ($to === 'completed') {
+        $check = $this->is_order_ready_for_completion($order);
+        if ($check['ready']) {
+            return;
+        }
+        self::$inside_self_revert = true;
+        try {
+            $order->update_status(
+                $from,
+                __('Cannot complete: product line items still pending print. Resolve item statuses first.', 'studiou-wc-ord-print-statuses')
+            );
+        } finally {
+            self::$inside_self_revert = false;
+        }
+        UtilsLog::message(
+            sprintf(
+                __('Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".', 'studiou-wc-ord-print-statuses'),
+                $order_id,
+                count($check['blocking'])
+            ),
+            'error'
+        );
+    }
+}
+```
+
+**Caveats:**
+
+1. `woocommerce_order_status_changed` fires *after* the change is committed. Reverting via `update_status` is post-hoc — the order shows the reverted state on the next page load. Slightly disorienting but workable, and the admin notice explains it.
+2. The static `$inside_self_revert` flag prevents infinite recursion when the revert itself fires the hook.
+3. For cleaner UX on the admin form path, a complementary interception in `woocommerce_admin_process_order_object` can validate **before** save. The post-hoc revert still covers programmatic / API / protocol paths. Belt-and-braces.
+
+---
+
+## 9. UI mockup (text)
+
+In the order edit screen's Items meta box, the table grows by one column:
+
+```
+| Item                  | Cost   | Qty | Total  | Print Status         |
+|-----------------------|--------|-----|--------|----------------------|
+| Product A (variation) | 100.00 |  2  | 200.00 | [Pending Print  ▼]   |
+| Product B (variation) |  50.00 |  1  |  50.00 | [Done Print     ▼]   |
+| Shipping              |      — |  —  |  10.00 | —                    |
+| Coupon: SUMMER10      | -20.00 |  —  | -20.00 | —                    |
+```
+
+Non-product items show `—` and aren't editable.
+
+Optional v1.5.0 sweetener: a one-line summary above the table — *"Print readiness: 3 of 4 items ready (1 pending)."*
+
+---
+
+## 10. Edge cases & open behaviour decisions
+
+| Scenario | Proposed behaviour |
+|----------|---------------------|
+| Order has 0 product items | Completion allowed (vacuously satisfies the guard). |
+| Order has only refunds / shipping / fees | Completion allowed. |
+| Existing `wc-completed` order with items in `pending-print` | No effect — the guard only fires on the *transition* into `completed`, not on stored state. |
+| Manual order status flip Pending → Completed (skipping in-print) | Guard fires. Items are still `pending-print` → blocked. Operator must advance items or use protocol imports. |
+| Order in `wc-in-print` reverted to `wc-processing` then back to `wc-in-print` | First revert: no item change (per §3). Second forward: pending items advance again (no-op since they're already `in-print`). Done items stay done. |
+| Item is removed from order after being set to `done-print` | The meta is deleted along with the item. No orphan record. |
+| Item status changed *after* order is `completed` | Allowed. Order doesn't revert. |
+| Bulk action "Set Status to In Printing" on 100 orders | Each order's pending items propagate to `in-print` via the listener. |
+| Multiple `wc-in-print` ↔ `wc-to-print` ping-pong | Each → `in-print` advances any remaining `pending-print` items. Items previously moved stay in `in-print`. |
+
+---
+
+## 11. Test plan
+
+### 11.1 Unit-style scenarios
+
+1. **Default value:** new order item has no `_print_status` meta; `get_status()` returns `'pending-print'`.
+2. **Set status:** `set_status($item, 'in-print')` writes the meta and (with `$audit=true`) appends an order note.
+3. **Invalid status:** `set_status($item, 'foo')` rejects and returns `false` without writing.
+4. **Completion guard ready:** all items in `done-print` or `skip-print` — `is_order_ready_for_completion()` returns `{ready:true}`.
+5. **Completion guard blocked:** at least one item in `pending-print` or `in-print` — returns `{ready:false, blocking:[…]}`.
+6. **Non-product items ignored:** an order with one product item (`done-print`) and one shipping item — guard returns ready.
+7. **`advance_pending_to_in`:** order with items `[pending, in, done, skip]` → result has items `[in, in, done, skip]`. Count = 1.
+8. **`advance_remaining_to_done`:** order with items `[pending, in, done, skip]` → result `[done, done, done, skip]`. Count = 2.
+
+### 11.2 Integration
+
+1. **Admin UI manual change:** open an order with three items, change one to `done-print`, click Update, reload → meta persisted, audit note added.
+2. **Propagation:** change order status to `wc-in-print` via the admin dropdown → all `pending-print` items in the order are now `in-print`. Order note from `update_status` plus per-item audit notes.
+3. **Completion guard via admin:** with one item still `pending-print`, change order status to `wc-completed`, click Update → order reverts to previous status (visible after reload), admin notice shows the block reason.
+4. **Completion guard via programmatic call:** `wp wc shop_order update <id> --status=completed` on an order with `pending-print` items → status reverts.
+5. **InPrint Protocol import:** prepare a CSV with one `order_no`, run the import → order is in `in-print`, all the order's `pending-print` items advanced to `in-print` via the listener, `done-print` and `skip-print` items untouched. Import notice mentions advanced count.
+6. **Delivered Protocol import:** prepare a CSV with one `order_no`, run the import → items advance to `done-print` first, then order flips to `completed`. Guard is satisfied. Import notice mentions advanced count.
+7. **CSV export:** "Prepare to Printing Export" bulk action — output contains a `prod_print_status` column with per-item values.
+
+### 11.3 Regression
+
+1. The "Set Status to Prepare to Printing" bulk action still works without touching items (no propagation on `→ to-print`).
+2. The "Set Status to In Printing" bulk action now also propagates items (new behaviour — expected).
+3. Existing completed orders are unaffected by the new guard.
+4. The Print Information panel on the order edit screen still renders.
+5. The custom orders-list columns still render.
+6. The order search by external reference still works.
+
+---
+
+## 12. Rollout
+
+### 12.1 Versioning
+
+- New plugin version: **1.5.0** (minor — feature addition, no breaking changes).
+- `STUDIOU_WC_OPS_VERSION` constant updated accordingly.
+
+### 12.2 Activation behaviour
+
+- No database migration. Lazy defaults handle existing items.
+- No new tables. The `woocommerce_order_itemmeta` table is already created by WC.
+
+### 12.3 Deactivation behaviour
+
+- Same policy as today: meta keys are not cleaned up. Reactivation preserves all data.
+
+### 12.4 Internationalisation
+
+~12 new translatable strings:
+
+- 4 status labels: `Pending Print`, `In Print`, `Done Print`, `Skip Print`.
+- 1 column header: `Print Status`.
+- 1 placeholder for non-product items: `—` (literal, not translated).
+- 1 completion-guard order note: `Cannot complete: product line items still pending print. Resolve item statuses first.`
+- 1 admin notice: `Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".`
+- 1 audit-note format: `Item #%1$d (%2$s): %3$s → %4$s.`
+- 2 protocol-auto-advance order notes: `%d items advanced to In Print by order-status change.`, `%d items advanced to Done Print by Delivered Protocol import.`
+- 1 readiness summary (optional UI sweetener): `Print readiness: %1$d of %2$d items ready (%3$d pending).`
+
+### 12.5 Documentation
+
+- README: new "Order item statuses" feature section + coupling rules table + 1.5.0 changelog entry + Known Limitations updated (block-based screen still unsupported).
+- CLAUDE.md: new manager added to the manager-pattern list + Recent Changes entry.
+- New review doc (post-release): `docs/revise-1.5.0.md` once the feature lands.
+- `docs/feature-order-item-status-analysis.md` (this document) → archive or supersede after implementation.
+
+### 12.6 Phasing (suggested)
+
+- **Phase 1 (v1.5.0):** core feature — data model, admin UI, propagation listener, completion guard, Delivered Protocol explicit advance, CSV export column. Classic order edit screen only.
+- **Phase 2 (v1.5.x or later):** AJAX live status changes (no full page reload), bulk-change items across orders, optional `skip-print` reason field, block-based order edit screen support.
+
+---
+
+## 13. Effort estimate
+
+Rough sizing for Phase 1:
+
+| Area | Effort |
+|------|--------|
+| `Order_Item_Status_Manager` class (data model + admin UI + combined propagation/guard listener) | ~6–10 hours |
+| `Studiou_DB_Manager` integration (Delivered Protocol advance, CSV column with new join) | ~2–3 hours |
+| Bootstrap + manager wiring + version bump | ~30 minutes |
+| README / CLAUDE.md / translations / .po | ~2 hours |
+| Test pass (manual scenarios in §11) | ~3–4 hours |
+| **Total** | **~14–20 hours** |
+
+Phase 2 items add ~6–10 hours each depending on scope.
+
+---
+
+## 14. Risk register
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|------------|--------|------------|
+| Listener recursion (propagation → save → propagation, or guard-revert → revert → revert) | Medium | High | Static `$inside_self_revert` flag; propagation only fires on `→ in-print` not on every save. |
+| Existing orders break on first edit because meta is missing | Low | Low | Lazy default handles this — `get_status` returns `pending-print` for missing meta. |
+| Operator surprise: clicking "Set Status to In Printing" silently advances items | Low | Low | Documented in README; per-item audit notes show what happened. |
+| Block-based order edit screen doesn't render the new column | Medium | Low | Documented as a known limitation since 1.4.1; carried forward. |
+| `_print_status` meta key collides with another plugin | Very low | Medium | If collision found, fall back to a namespaced key (`_studiou_print_status`). |
+| Delivered Protocol "advance to done" advances items the operator intended to leave in `skip-print` | None | — | `skip-print` already satisfies the completion guard; the advance helper only touches `pending-print` / `in-print`. |
+
+---
+
+## 15. Recommendation
+
+Implement Phase 1 as described above, subject to stakeholder confirmation of the §4 open questions — particularly:
+
+- Open question 1 (propagation on `→ in-print`). The proposed design assumes "yes, advance pending items". This is the workflow-consistent choice, but it does change the behaviour of the existing **bulk action "Set Status to In Printing"** (previously order-only, now also touches items). If the stakeholder prefers the bulk action to remain strictly order-only, the propagation rule can be limited to specific paths (e.g., only the InPrint Protocol import) — but that re-introduces some of the awkwardness the old §2.5 grappled with.
+
+The shape of the design with requirement #3 dropped is materially cleaner than the original analysis: a single listener implements both behaviours, the protocol-import code paths are minimally changed, and the rules can be summarised in one table (§3).
+
+---
+
+*End of analysis.*

+ 0 - 0
studiou-wc-ord-print-statuses/instructions.txt → studiou-wc-ord-print-statuses/docs/instructions.txt


+ 0 - 0
studiou-wc-ord-print-statuses/readme.md → studiou-wc-ord-print-statuses/docs/readme.md


+ 291 - 0
studiou-wc-ord-print-statuses/docs/revise-1.3.2.md

@@ -0,0 +1,291 @@
+# Plugin Revision — v1.3.2 (Analytical Review)
+
+**Scope:** Read-only analytical pass over the entire codebase of `studiou-wc-ord-print-statuses` at version 1.3.2. No code changes were made. This document catalogs gaps, inconsistencies, and risks discovered by reviewing each manager class against the plugin header, README, CLAUDE.md, and the original feature specification in `docs/instructions.txt`.
+
+**Reviewer date:** 2026-05-12
+**Files reviewed:**
+- `studiou-wc-ord-print-statuses.php`
+- `includes/class-db-manager.php`
+- `includes/class-order-status-manager.php`
+- `includes/class-order-fields-manager.php`
+- `includes/class-order-search-manager.php`
+- `includes/class-bulk-actions-manager.php`
+- `includes/class-custom-columns-manager.php`
+- `includes/class-import-manager.php`
+- `includes/utils-log.php`
+- `README.md`, `CLAUDE.md`, `docs/instructions.txt`, `docs/translations.txt`
+
+Findings are grouped by severity. The severity reflects user-visible or correctness impact, not implementation effort to fix.
+
+---
+
+## 1. Critical — features that are advertised but do not work
+
+### 1.1 Search by External Order Number is broken (three independent defects)
+
+The feature is documented in README §5 and CLAUDE.md §"Order_Search_Manager", and matches spec item 3 in `docs/instructions.txt`. The current implementation cannot work for the following reasons:
+
+**Defect A — Meta key mismatch (would fail even under legacy CPT)**
+
+- The field is **saved** as `external_ref_ord_no` (no leading underscore):
+  - `class-order-fields-manager.php:58` — `$order->update_meta_data('external_ref_ord_no', …)`
+  - `class-db-manager.php:184` — `$order->update_meta_data('external_ref_ord_no', $row['externalorder'])`
+- The field is **searched** as `_external_ref_ord_no` (with leading underscore):
+  - `class-order-search-manager.php:18`
+  - `class-order-search-manager.php:53` (`'key' => '_external_ref_ord_no'`)
+
+Even if every other hook below were correct, the `meta_query` LIKE match would always return zero rows.
+
+**Defect B — Wrong hook for HPOS environment**
+
+`class-order-search-manager.php:8` hooks `parse_query` and gates with:
+
+```php
+if ('edit.php' !== $pagenow || 'shop_order' !== $typenow || !$query->is_main_query())
+    return $query;
+```
+
+Under HPOS, the orders screen is served from `admin.php?page=wc-orders` (not `edit.php`) and `$typenow` is not `'shop_order'`. The guard always returns early; `meta_query` is never injected. The plugin declares HPOS compatibility, so this code path is dead in the supported configuration.
+
+**Defect C — Search field is mounted as a column renderer, not a toolbar field**
+
+- `class-order-search-manager.php:6` hooks `add_external_ref_ord_no_to_search` to `manage_woocommerce_page_wc-orders_columns` — that filter expects the orders-list columns array, not a "searchable fields" array. The callback appends `_external_ref_ord_no` as if it were a column key, creating a phantom column.
+- `class-order-search-manager.php:7` hooks `add_external_ref_ord_no_search_field` to `manage_woocommerce_page_wc-orders_custom_column` — that hook fires **per table cell**. The callback renders an `<input type="text">`, which (if the typenow guard ever passed) would draw a text input inside every row's cell rather than beside the "Search Order" button as the spec requires.
+
+**Net effect:** The search box does not render in the expected place, the search hook does not fire under HPOS, and even on a legacy CPT setup the meta key mismatch would zero out results. This feature is non-functional.
+
+### 1.2 `Order_Status_Manager::handle_status_transitions()` writes to the wrong table under HPOS
+
+`class-order-status-manager.php:59-62`:
+
+```php
+update_post_meta($order_id, 'to_print_date', current_time('mysql'));
+…
+update_post_meta($order_id, 'in_print_date', current_time('mysql'));
+```
+
+`update_post_meta()` writes to `wp_postmeta`. Under HPOS, WooCommerce reads order meta from `wp_wc_orders_meta`. The result:
+
+- Whenever a status transitions to `to-print` / `in-print` **outside** of the bulk-action or import paths (e.g., manual change from the order edit screen, programmatic transitions from third-party code), the timestamp is written only to `wp_postmeta` and is invisible to `$order->get_meta()`. The custom column and the "Print Information" panel will show empty values.
+- The bulk and import paths use `$order->update_meta_data() + $order->save()` (correct under both storage modes), so they happen to work — masking the bug for the common user flow.
+
+This is a latent HPOS gap directly contradicting CLAUDE.md §"Important Considerations" point 1.
+
+---
+
+## 2. High — correctness, HPOS gaps, spec divergence
+
+### 2.1 Original spec requires CSV row deduplication; implementation does none
+
+`docs/instructions.txt` lines 28 and 35 both state: *"Distinct values from import file by column 'Order No'"*.
+
+`Studiou_DB_Manager::import_inprint_protocol()` and `import_delivered_protocol()` iterate every row and call `wc_get_order()` + `update_status()` + `save()` per occurrence. If the same `order_no` appears twice in a CSV (a realistic provider-side glitch), the order is mutated twice, the success counter increments twice, and the user gets a misleading "X orders updated" total.
+
+### 2.2 CSV header casing — spec vs. implementation discrepancy
+
+Spec (`docs/instructions.txt` lines 27, 33) specifies headers `"Order No"`, `"ExternalOrder"`, `"ExternalOrderDate"`. The code reads `$row['order_no']`, `$row['externalorder']`, `$row['externalorderdate']` (lowercased, no spaces). README documents the lowercase form, matching the code.
+
+This is a documentation/spec drift rather than a code bug, but it means CSV files prepared from the original instructions will silently fail header lookup (`!isset(...)` branch returns the generic "Invalid row format in CSV.").
+
+### 2.3 Bulk export status change is non-atomic with the export
+
+`class-bulk-actions-manager.php:34-50`:
+
+```php
+$orders_data = Studiou_DB_Manager::get_orders_for_printing($post_ids);
+$this->set_status_to_prepare_to_printing($post_ids, $redirect_to); // ← changes status first
+…
+$this->output_csv($csv_data, 'prepare_to_printing_export.csv');     // ← then output (exits)
+```
+
+If the CSV stream fails mid-output (proxy timeout, client disconnect), orders are already marked `to-print`. The user has no recovery signal and the file may be incomplete. If `output_csv` were ever to throw before `exit`, the bulk-action redirect would still claim success.
+
+Additionally, the `$redirect_to` returned from the inner `set_status_to_prepare_to_printing()` call is discarded — the side effect of adding `bulk_action=marked_prepare_to_printing` to the query is wasted.
+
+### 2.4 Export SQL multiplies rows for products in multiple categories
+
+`Studiou_DB_Manager::get_orders_for_printing()` joins `term_relationships`/`term_taxonomy`/`terms` unconstrained. A product mapped to N `product_cat` terms produces N rows in the CSV for the same order line. If the print provider expects one row per line item, the file will contain duplicate quantities. Whether this is intentional is unclear from the spec; the spec says "custom sql 'SELECT * FROM vsu_orders_car_prod_qty_img'" without defining cardinality.
+
+### 2.5 CSV export has no UTF-8 charset signal and no Excel BOM
+
+`Bulk_Actions_Manager::output_csv()` (lines 84-89) sends:
+
+```
+Content-Type: text/csv
+Content-Disposition: attachment; filename="..."
+```
+
+No `charset=utf-8`, no UTF-8 BOM. Czech product/category names containing diacritics will render as mojibake when opened in Excel on Windows (the most likely consumer for a print-shop export). Field separator is comma; Excel in Czech locale prefers semicolon.
+
+### 2.6 `set_order_processing_status()` can demote print-state orders
+
+`class-order-status-manager.php:65-74` forces any payment-complete order into `processing` unless it is already `completed`. If a payment settles late on an order already moved to `to-print` or `in-print` (e.g., a delayed bank transfer), the order is silently rolled back to `processing` and the `to_print_date` / `in_print_date` timestamps remain, leaving the order in an inconsistent state vs. its meta.
+
+The guard should exclude all print statuses, not just `completed`.
+
+### 2.7 Import handlers reject malformed rows but do not validate file structure
+
+`Studiou_DB_Manager::parse_csv()`:
+- Returns an empty array (not `false`) if the file is missing or empty, but the callers check `if (!$csv_data)` — empty array is falsy in PHP, so the "Error parsing CSV file." path is reached. OK in practice, but reliance on falsy-vs-false is fragile.
+- Reads each row with `fgetcsv($handle, 1000, ',')` — the **1000-byte line cap will truncate** any row longer than 1 KB. Long product names or notes in delivered protocols could be silently chopped.
+- Does not check `$_FILES[...]['error']` (handles UPLOAD_ERR_INI_SIZE, etc.).
+- Does not verify the MIME type server-side; the `accept=".csv"` attribute is client-side only.
+- Does not verify the first line is a header (if a CSV without headers is uploaded, `array_combine` may emit a warning and produce nonsense rows).
+
+### 2.8 Bulk handlers lack a capability check
+
+`Bulk_Actions_Manager::handle_bulk_actions()` trusts WordPress's per-screen capability enforcement. WP does check `edit_shop_orders` to display the bulk action menu, but defense in depth would call `current_user_can('edit_shop_orders')` (or `manage_woocommerce`) inside the handler. Compare with `Import_Manager`, which does call `current_user_can('manage_woocommerce')` — the inconsistency itself is worth noting.
+
+---
+
+## 3. Medium — robustness, maintainability, internal consistency
+
+### 3.1 `UtilsLog::message()` is dead code
+
+`includes/utils-log.php:15-27` — the entire body is commented out. The method is called in at least three places (`class-db-manager.php:128, 153`; `class-bulk-actions-manager.php:42`). The user-facing notice intent (`type: info`, `dismissible: true`) is never delivered. Bulk operations therefore have **no visible feedback** in the admin UI for non-debug users.
+
+### 3.2 `UtilsLog::log()` guard is overly strict
+
+`if ( true === WP_DEBUG )` requires `WP_DEBUG` to be the boolean literal `true`. If the user defines `define('WP_DEBUG', 1)` (common in legacy `wp-config.php` examples), nothing logs. The conventional guard is `if ( defined('WP_DEBUG') && WP_DEBUG )`.
+
+### 3.3 `set_orders_to_prepare_printing` logs per iteration; `set_orders_to_in_printing` does not
+
+`class-db-manager.php:120-131` logs entry, success, and not-found for each order. `class-db-manager.php:143-157` logs neither entry, success, nor not-found. Symmetry would aid debugging.
+
+### 3.4 No transaction or state-machine guard on status transitions
+
+`Studiou_DB_Manager::set_orders_to_*()` calls `update_status()` regardless of current state. An order in `cancelled` or `refunded` would be reactivated into `to-print` — almost certainly unintended. The bulk action UI itself does not filter eligible orders.
+
+### 3.5 Plugin-init order risk for translations
+
+`studiou-wc-ord-print-statuses.php:101-107` registers two `plugins_loaded` callbacks. WordPress runs them in registration order. `Order_Status_Manager::__construct()` calls `_x()` / `_n_noop()` inside the constructor (line 9-15). If `Studiou_WC_Ord_Print_Statuses::initPlugin` registers before `studiou_wc_ord_print_statuses_load_textdomain`, those translation calls fire before the text domain is loaded, and untranslated strings are cached. Order is currently: `initPlugin` registered after `load_textdomain`, so OK *today*, but the dependency is implicit and fragile.
+
+### 3.6 SQL injection defense relies on `intval()`, not `$wpdb->prepare()`
+
+`class-db-manager.php:93`:
+
+```php
+WHERE `orders`.`id` IN (" . implode(',', array_map('intval', $order_ids)) . ")
+```
+
+Using `intval()` on each ID is functionally safe — it coerces to integer and emits zero on garbage input. However, CLAUDE.md §"Important Considerations" point 4 misleadingly claims this *"uses `$wpdb->prepare()` implicitly through `intval()`"* — that is not accurate; `$wpdb->prepare()` is never called. The documentation should match what the code actually does.
+
+### 3.7 Logging volume on every request
+
+Every page-load triggers `UtilsLog::log` calls in `init()`, `load_dependencies()`, `initialize_managers()`, `register_custom_order_statuses()`, `register_bulk_actions()`, etc. With `WP_DEBUG_LOG` enabled, the log fills with init noise. Useful logs (status transitions, SQL errors) get drowned. Consider gating init logs behind a higher verbosity flag.
+
+### 3.8 `parse_csv` line length
+
+`fgetcsv($handle, 1000, ',')` — the second parameter is the max line length in bytes. 1000 bytes is short for a row that contains a long order note or product title plus diacritics. Passing `0` removes the cap. Worth raising explicitly.
+
+### 3.9 Imported `external_ref_ord_date` is stored verbatim
+
+`class-db-manager.php:185` stores `$row['externalorderdate']` without any date parsing or validation. If the provider sends `31/12/2025` while WP expects ISO format elsewhere, downstream filters and the order-detail panel may render an empty value. The "Print Information" panel does not display this field at all — it is only readable via direct meta access.
+
+### 3.10 `external_ref_ord_date` is never exposed in the UI
+
+The field is written by `import_inprint_protocol()` but no manager renders it. Neither `Order_Fields_Manager` nor `Custom_Columns_Manager` references it. If the print-shop's order date matters operationally, it is invisible to the operator.
+
+### 3.11 No uninstall / deactivation cleanup
+
+There is no `uninstall.php`, no `register_deactivation_hook`, no `register_uninstall_hook`. Custom post statuses are registered every `init`, so they vanish when the plugin is deactivated — but order meta keys (`to_print_date`, `external_ref_ord_no`, etc.) remain in the DB indefinitely. For long-term hygiene on test sites, consider documenting that removal requires a manual SQL cleanup.
+
+### 3.12 No version constant
+
+The plugin version lives only in the header comment (`Version: 1.3.2`). To reference it in code (e.g., for asset cache-busting or upgrade routines) one must call `get_file_data()`. Defining `define('STUDIOU_WC_OPS_VERSION', '1.3.2');` once in the bootstrap would be a minor consistency improvement and matches the pattern used in sibling plugins per CLAUDE.md.
+
+### 3.13 HPOS column callback parameter naming
+
+`Custom_Columns_Manager::add_custom_shop_order_column_content($column, $post_id)` — under HPOS the second argument is actually a `WC_Order` object, not a post ID. `wc_get_order()` accepts both, so it works, but the parameter name is misleading and shadows the legacy CPT contract. Renaming to `$order_or_id` or capturing both branches explicitly would aid readers.
+
+---
+
+## 4. Low — documentation, cosmetic, style
+
+### 4.1 Translation reference (`docs/translations.txt`) is incomplete
+
+Strings present in code but absent from the EN→CS reference:
+
+- `"Order automatically set to processing by Studiou WC Order Print Statuses plugin."` (`class-order-status-manager.php:72`)
+- `"Payment received, order is now processing."` (`class-order-status-manager.php:70`)
+- `"Search by External Order Number"` (`class-order-search-manager.php:30`)
+- `"You do not have sufficient permissions to access this page."` (`class-import-manager.php:48, 79`)
+- `"Error parsing CSV file."` (`class-import-manager.php:61, 93`)
+- `"External Order Delivered"` field label (`class-order-fields-manager.php:38`)
+- `"In Print Date"` field label (`class-order-fields-manager.php:25`)
+
+These strings would compile into the `.pot` via `wp i18n make-pot` but were never reviewed by the translator.
+
+### 4.2 README/CLAUDE.md claim full HPOS compatibility
+
+Both documents advertise HPOS compatibility without qualification. Given finding §1.1 (search broken under HPOS) and §1.2 (status-transition stamps go to the wrong table), the claim is overstated until those are fixed.
+
+### 4.3 CLAUDE.md is the only place where the SQL-prepare claim appears
+
+Mentioned in §3.6 — `CLAUDE.md` should be corrected regardless of whether the code is changed, because it currently misrepresents the security posture.
+
+### 4.4 Inconsistent comment language
+
+Some class files open with `// visualized` (in `class-bulk-actions-manager.php`, `class-import-manager.php`, `class-order-fields-manager.php`, `class-custom-columns-manager.php`) — apparently a leftover marker from an earlier review pass. It conveys nothing to a fresh reader and should be removed or replaced with a proper docblock.
+
+### 4.5 `Order_Status_Manager::add_custom_order_statuses_to_list` formatting
+
+`class-order-status-manager.php:48-49`:
+
+```php
+            }        }
+```
+
+Two `}` on the same line, asymmetric indentation. Cosmetic only.
+
+### 4.6 No `Network:` declaration in plugin header
+
+Minor — only relevant for multisite installs.
+
+### 4.7 README architecture table omits `Studiou_DB_Manager` and `UtilsLog` from the manager pattern explanation
+
+Already listed in the table but worth noting that DB Manager is a different category (static service) from the other "manager" classes (hook-registering instances). The README treats them as peers.
+
+---
+
+## 5. Spec compliance matrix (against `docs/instructions.txt`)
+
+| Spec item | Requirement | Status | Notes |
+|-----------|-------------|--------|-------|
+| 1 | Custom statuses `wc-to-print`, `wc-in-print` after `wc-processing` | ✅ Implemented | Inserted correctly via `wc_order_statuses` filter. |
+| 2 | Add fields `to-print-date`, `in-print-date`, `external-ref-ord-no`, `external-ref-ord-delivered` | ⚠️ Partial | Stored with underscore keys (acceptable). `external_ref_ord_date` exists in code but is **not in the spec** and not surfaced in UI. |
+| 3 | Search orders by `external-ref-ord-no` near Search Order button | ❌ Broken | See §1.1 — three independent defects. |
+| 4 | Three bulk actions | ✅ Implemented | Behaviour matches; concerns about non-atomic export (§2.3) and category multiplication (§2.4). |
+| 5 | Three columns appended to orders grid | ✅ Implemented | Inserted after `order_total` rather than strictly "at the end", but visible and functional. |
+| 6 | Import InPrint protocol with deduplication | ⚠️ Partial | Import works; **deduplication not implemented** (§2.1). CSV headers diverge from spec casing (§2.2). |
+| 7 | Import Delivered protocol with deduplication | ⚠️ Partial | Same as item 6. |
+
+---
+
+## 6. Risk-prioritised summary
+
+Issues most likely to cause a user-reported bug, ordered by expected impact:
+
+1. **§1.1 Search by External Order Number does not work** (advertised feature, three defects).
+2. **§1.2 Status-transition timestamps lost under HPOS for non-bulk transitions** (silent data loss).
+3. **§2.5 CSV export mojibake / Excel locale mismatch** (operator-facing, daily friction).
+4. **§2.1 Import dedup missing** (silently double-applies status changes).
+5. **§2.6 Payment-complete demotion of print-state orders** (workflow regression).
+6. **§2.3 Bulk export is non-atomic** (rare, but unrecoverable when it triggers).
+7. **§3.1 `UtilsLog::message()` is dead** (no user feedback after bulk actions).
+8. **§3.4 No status-machine guards** (cancelled/refunded orders can be re-activated by bulk action).
+9. **§2.2 CSV header casing drift vs. original spec** (provider-side surprise).
+10. **§3.6 + §4.3 CLAUDE.md misrepresents SQL safety** (documentation, not code).
+
+---
+
+## 7. Out of scope for this review
+
+- Performance profiling under large order volumes (the export query is unbounded).
+- Browser/JS interaction — there is no front-end code in this plugin.
+- Cross-plugin compatibility with `studiou-wc-product-cat-manage` or `studiou-wc-free-photo-product` (mentioned as siblings in recent git history).
+- Functional testing in a live WP/WC install. All findings above are derived from source reading.
+
+---
+
+*End of review.*

+ 344 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.0.md

@@ -0,0 +1,344 @@
+# Plugin Revision — v1.4.0 (Analytical Review)
+
+> **Status as of 1.4.1 (2026-05-12):** all in-scope findings below have been resolved in plugin version **1.4.1**. Each finding now carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Read-only analytical pass over the v1.4.0 codebase. No code changes were made *as part of this review*; the resolution was implemented separately and is recorded in the status markers below. This document is the counterpart of [`revise-1.3.2.md`](revise-1.3.2.md) — it catalogues residual gaps, possible syntax pitfalls, regressions newly introduced by the 1.4.0 fix-up, and items from the prior review that remain open.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, plus `studiou-wc-ord-print-statuses.php`, `README.md`, `CLAUDE.md`, `docs/translations.txt`.
+**Verification limitations:** No PHP interpreter and no WordPress / WooCommerce install were available in the working environment, so syntax errors visible only at compile time are not ruled out and WC-internal filter names / hook semantics could not be cross-checked against a running site. The findings below reflect WC 10.7.0 / WP 6.9.4 as the *target* but are derived from source reading, not live behaviour.
+
+Findings are grouped by severity. The severity reflects user-visible or correctness impact, not implementation effort to fix.
+
+---
+
+## 1. Critical — newly introduced regressions
+
+### 1.1 Export SQL `GROUP BY` may fail under default MySQL strict mode — ✅ resolved in 1.4.1
+
+`class-db-manager.php:35-76` — the new export query introduces `GROUP BY orders.id, order_products.product_id, order_products.variation_id` so that products with multiple categories no longer duplicate rows. The `SELECT` list, however, contains several non-aggregated columns that are **not** in the `GROUP BY` clause:
+
+- `products.post_title`
+- `product_variations.post_title`
+- `product_variations_name.name`
+- `images.guid`
+- `order_products.product_qty`
+- `orders.billing_email`
+
+Under MySQL 5.7+ and MariaDB 10.3+ the default `sql_mode` includes `ONLY_FULL_GROUP_BY`. MySQL does recognise *functional dependency* on a primary key column, so `products.post_title` is technically dependent on `order_products.product_id` (which references `products.ID`). In practice the recognition is brittle: it depends on engine version, whether the FK is declared, the optimiser's understanding of the join, and the exact sql_mode.
+
+**Risk:** the bulk export may fail with `ERROR 1055 (42000): Expression #N of SELECT list is not in GROUP BY clause and contains nonaggregated column ...` on installations that follow the MySQL default. The previous v1.3.2 query had no `GROUP BY` and was immune.
+
+**Mitigation paths** (informational only):
+- Wrap every non-aggregated column in `ANY_VALUE(...)` (MySQL 5.7+) or `MIN(...)`.
+- Add every non-aggregated column to `GROUP BY` explicitly.
+- Reduce the join with a sub-query that pre-aggregates `product_cat`, then join in the rest without `GROUP BY`.
+
+### 1.2 CSV exports now write a UTF-8 BOM; CSV imports do not strip it — ✅ resolved in 1.4.1
+
+`class-bulk-actions-manager.php:136` writes a leading UTF-8 BOM (`\xEF\xBB\xBF`) so Excel on Windows interprets the file as UTF-8. The matching read side `Studiou_DB_Manager::parse_csv()` (`class-db-manager.php:224-248`) does **not** strip a BOM from the first byte of the first header.
+
+**Symptom:** if a user exports `prepare_to_printing_export.csv`, edits it, and re-imports it as a protocol, the first header is read as `\xEF\xBB\xBForder_no` instead of `order_no`. The header normaliser (`normalise_header`, line 250) lowercases and collapses whitespace but does not touch the BOM. Subsequent `isset($row['order_no'])` checks fail for every row, the import emits "Invalid row format in CSV." for the entire file, and no orders are updated.
+
+This combination is realistic — exports often serve as templates for the next stage. Stripping a BOM is a single-line fix in `parse_csv`.
+
+---
+
+## 2. High — correctness, HPOS, time-zone
+
+### 2.1 HPOS search filter name is unverified against WC 10.7.0 — ⏸ deferred (verification task)
+
+`class-order-search-manager.php:20` hooks `woocommerce_order_table_search_query_meta_keys`. The legacy CPT filter `woocommerce_shop_order_search_fields` (line 17) is well-established. The HPOS filter's exact name has varied across WC releases and could not be confirmed against WC 10.7.0's source without access to a running install.
+
+**Risk:** if the filter name is wrong (or has been renamed in the active WC 10.7.0 build), search by external order number works only under legacy CPT, not under HPOS — which is exactly the failure mode the v1.3.2 review flagged as critical.
+
+**Verification ask (WC 10.7.0):** confirm the filter still exists, e.g. by `grep -RIn 'woocommerce_order_table_search_query_meta_keys' wp-content/plugins/woocommerce/`, or by adding a temporary `error_log` inside the callback and observing whether it fires on the orders page with HPOS enabled. If the filter has been renamed, alternatives to consider in WC 10.x are `woocommerce_order_query_args` / `woocommerce_orders_table_query_clauses` for injecting `meta_query` at a higher level.
+
+### 2.2 Datetime fields use server timezone, not WP timezone — ✅ resolved in 1.4.1
+
+Resolution: switched `Order_Fields_Manager` to pure-string round-tripping (no `strtotime()`/`date()`). The form value and the stored value are now both treated as WP local time, matching `current_time('mysql')`.
+
+`class-order-fields-manager.php:90` and `:99` use `date('Y-m-d\TH:i', $timestamp)` and `date('Y-m-d H:i:s', $timestamp)` for the `datetime-local` round-trip. PHP's `date()` formats with the server's PHP timezone, not WordPress's site timezone.
+
+Elsewhere the plugin stamps timestamps with `current_time('mysql')` — which honours `wp_timezone()` / `get_option('timezone_string')`. As a result:
+
+- Bulk action and import paths stamp `to_print_date` etc. in WP local time.
+- Manual edits via the Print Information panel display and save in server local time.
+- If the server is UTC and WP timezone is `Europe/Prague` (UTC+1 or +2 with DST), the panel shows values shifted by one or two hours.
+
+Replacing `date(...)` with `wp_date(...)` or constructing a `DateTimeImmutable` with `wp_timezone()` would unify behaviour.
+
+The orders-list column (`class-custom-columns-manager.php:58`) already does this correctly via `date_i18n(...)` — so the discrepancy is between the column display and the editable panel.
+
+### 2.3 Status-change listener and bulk-set both stamp the same meta — ✅ resolved in 1.4.1 (documented as intentional)
+
+Resolution: the listener and the bulk/import paths intentionally coexist — the listener owns manual single-order transitions, the bulk/import paths always re-stamp. A docblock above `handle_status_transitions` now explains the contract.
+
+`Order_Status_Manager::handle_status_transitions()` (lines 71-86) is triggered by every `woocommerce_order_status_changed` event. `Studiou_DB_Manager::bulk_set_print_status()` (lines 108-146) and `Studiou_DB_Manager::import_inprint_protocol()` both call `$order->update_status(...)` which fires that event, and *also* explicitly call `$order->update_meta_data($meta_key, current_time('mysql'))`.
+
+End result per status change via the bulk/import path:
+
+1. `update_status` → fires `woocommerce_order_status_changed` → listener stamps meta (only if currently empty).
+2. Caller then overwrites meta with another `current_time('mysql')` (potentially a millisecond later).
+3. Single `save()` persists the second value.
+
+The values are nearly identical, so this is **not a correctness bug**, but it is wasted work and a maintenance trap: future contributors changing one path may miss the other. The two paths should agree on a single source of truth — either remove the explicit stamping in the bulk/import path (let the listener handle it) or have the listener defer to the bulk/import path when the operation is bulk.
+
+A subtler asymmetry: the listener stamps **only if empty** (`if (!$order->get_meta($meta_key))`), the bulk path **always** stamps. On a second-pass bulk action, the bulk path overwrites; the listener's "only if empty" guard is bypassed for the bulk path but enforced for manual transitions. That divergence is intentional in one direction (bulk should re-stamp on each run) but should be commented in the code.
+
+---
+
+## 3. Medium — robustness, maintainability
+
+### 3.1 `GROUP_CONCAT` is silently bounded by `group_concat_max_len` — ✅ resolved in 1.4.1
+
+Resolution: `SET SESSION group_concat_max_len = 65535` is issued before the export query.
+
+`class-db-manager.php:38` uses `GROUP_CONCAT(DISTINCT t.name ORDER BY t.name SEPARATOR ', ')`. The MySQL default for `group_concat_max_len` is 1024 bytes; once exceeded the result is **silently truncated** with no error returned. Products belonging to many or long-named categories may lose the tail of `prod_cat` without warning.
+
+Either raise the limit with `SET SESSION group_concat_max_len = 65535` before the query, or accept the truncation and document the limit.
+
+### 3.2 `parse_csv` does not skip the UTF-8 BOM — ✅ resolved in 1.4.1 (see §1.2)
+
+Related to §1.2. Even outside of the export-and-reimport workflow, any CSV authored in Notepad with "UTF-8 with BOM" encoding will hit the same issue. A single-line guard:
+
+```php
+$first = fread($handle, 3);
+if ($first !== "\xEF\xBB\xBF") {
+    rewind($handle);
+}
+```
+
+inside `parse_csv` would close this gap.
+
+### 3.3 `output_csv` fallback path mixes CSV into HTML on header-sent edge — ✅ resolved in 1.4.1
+
+Resolution: when `headers_sent()` returns true, the source file/line is logged and the request `wp_die()`s with a clear message instead of streaming CSV bytes into the HTML body.
+
+`class-bulk-actions-manager.php:130-138`:
+
+```php
+private function output_csv($csv_data, $filename) {
+    if (!headers_sent()) {
+        nocache_headers();
+        header('Content-Type: text/csv; charset=utf-8');
+        header('Content-Disposition: attachment; filename="' . $filename . '"');
+    }
+    echo "\xEF\xBB\xBF" . $csv_data;
+    exit;
+}
+```
+
+If headers have already been emitted (an upstream plugin printed something, a `print_r` left over from debugging, BOM in an included file…) the CSV is echoed without the right Content-Type. The browser will render it as part of the HTML page. Better: when `headers_sent()` returns true, log the event, queue an error notice via the new transient mechanism (problematic since we're about to exit), or `wp_die()` with a clear message instead of streaming garbage.
+
+### 3.4 `if ($file === null) { $this->redirect_back(); }` is statically unreachable-friendly — ✅ resolved in 1.4.1
+
+Resolution: `run_import()` now uses `return $this->redirect_back()` on every non-happy branch.
+
+`class-import-manager.php:88-91`:
+
+```php
+$file = $this->validated_upload($file_field);
+if ($file === null) {
+    $this->redirect_back();
+}
+$csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
+```
+
+`redirect_back()` ends with `exit;` so the code that follows is correctly unreachable, but static-analysis tools (Psalm, PHPStan) will flag `$file['tmp_name']` as a possible null-index access because the function signature on `redirect_back()` does not advertise it as a `noreturn`. Re-shaping as `if ($file === null) { return $this->redirect_back(); }` (or annotating `@return never`) makes intent explicit and silences the warning.
+
+### 3.5 `normalise_csv_date` returns the raw string on parse failure — ✅ resolved in 1.4.1
+
+Resolution: parse failures are logged and the function returns `''` so downstream UI is consistent (blank rather than "looks blank but has bogus data").
+
+`class-db-manager.php:271-278`:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') { return ''; }
+    $timestamp = strtotime($raw);
+    return $timestamp ? date('Y-m-d H:i:s', $timestamp) : $raw;
+}
+```
+
+On parse failure the raw string is stored. Downstream, `Order_Fields_Manager::mysql_to_datetime_local()` runs `strtotime` again, fails, and displays empty — so the operator silently loses the value. Either log the failure, surface a per-row import warning, or store an explicit sentinel (`''`) so the field is consistently blank rather than "looks blank, but has bogus data underneath".
+
+### 3.6 Unused constants and dead label maps — ✅ partially resolved in 1.4.1
+
+Resolution: `Order_Fields_Manager` now drives both rendering and saving from the `datetime_fields()` / `text_fields()` maps. The `STUDIOU_WC_OPS_VERSION` / `STUDIOU_WC_OPS_FILE` constants remain defined but unused inside the plugin — they are intentionally exported for external consumers (test fixtures, sibling plugins, custom code) and left in place.
+
+- `STUDIOU_WC_OPS_VERSION` and `STUDIOU_WC_OPS_FILE` are defined in the bootstrap (`studiou-wc-ord-print-statuses.php:22-23`) but never referenced inside the plugin. They are useful for code outside the plugin (test code, migrations), but if nothing reads them they are effectively documentation.
+- `Order_Fields_Manager::$datetime_fields` and `$text_fields` map keys to English labels, but the labels are never read — the rendered labels are hardcoded inline in `display_custom_order_fields()`. Either drop the labels (just keep the key arrays) or drive `display_custom_order_fields` from the maps to eliminate the duplication.
+
+### 3.7 `set_order_processing_status` produces two order notes per payment-complete event — ✅ resolved in 1.4.1
+
+Resolution: the explicit `add_order_note()` call was removed; the explanatory note is now passed as the second argument to `update_status()`.
+
+`class-order-status-manager.php:103-109`:
+
+```php
+$order->update_status('processing', __('Payment received, order is now processing.', ...));
+$order->add_order_note(__('Order automatically set to processing by Studiou WC Order Print Statuses plugin.', ...));
+```
+
+The second argument to `update_status` is already a note. Then a second note is added explicitly. The order activity log will show two consecutive lines for the same event. Choose one: pass the longer text as the `update_status` note, or drop the second-argument note and keep `add_order_note`.
+
+### 3.8 Bulk action result UX when zero orders are eligible — ✅ resolved in 1.4.1
+
+Resolution: a shared `notify_moved_count()` helper suppresses the "X orders moved" success notice when the count is zero. The "skipped because terminal status" warning from the DB manager stands alone.
+
+`class-bulk-actions-manager.php:52-78` — if every selected order is in a terminal state, `set_orders_to_prepare_printing` returns `0` and emits a "0 orders moved" notice. The DB manager additionally emits an "X orders skipped" warning. The "0 orders moved" notice is technically truthful but uninformative noise on top of the more useful "skipped" warning. Suppress the success notice when count is zero.
+
+### 3.9 PHP 8.4 deprecation of implicit `fputcsv` / `fgetcsv` escape character — ✅ resolved in 1.4.1
+
+Resolution: every `fputcsv` and `fgetcsv` call now passes `'"'` as the enclosure and `''` as the escape character explicitly.
+
+`class-bulk-actions-manager.php:117, 119` and `class-db-manager.php:232, 238` call `fputcsv` / `fgetcsv` with only the delimiter argument. From PHP 8.4 onward the implicit `escape='\\'` parameter is deprecated and emits warnings; passing an explicit empty string is the preferred forward-compatible form. The plugin currently requires PHP 7.2+ and does not pin a maximum, so installations on PHP 8.4 (released late 2024) will fill `debug.log` with deprecation warnings.
+
+### 3.10 `Order_Fields_Manager::save_custom_order_fields` lacks a capability check — ✅ resolved in 1.4.1
+
+Resolution: a `current_user_can('edit_shop_order', $order_id)` (with `'edit_shop_orders'` fallback) gate was added.
+
+The handler hooks `woocommerce_process_shop_order_meta`, which WC fires after its own form-handling pipeline that does include capability validation. So this is not exploitable in normal flow. Still, the pattern most third-party plugins follow includes a `current_user_can('edit_shop_order', $order_id)` (or the legacy `edit_post`) inside the callback for defense in depth, and to handle programmatic invocation from custom code paths.
+
+---
+
+## 4. Low — cosmetic, style, longer-term
+
+### 4.1 `dedupe_by_order_no` is case-sensitive — ✅ resolved in 1.4.1
+
+Resolution: keys are lowercased before insertion into the `$seen` set.
+
+`class-db-manager.php:257-269` — keys are `trim((string) $row['order_no'])`. Order IDs in WooCommerce are numeric so this is irrelevant in practice, but if any deployment uses an order-number plugin that produces alphabetic prefixes (e.g. `ORD-12345`), `ord-12345` and `ORD-12345` would not dedupe. Normalising to a single case in the dedup key is cheap insurance.
+
+### 4.2 `run_import` action-name dispatch is brittle — ✅ resolved in 1.4.1
+
+Resolution: dispatch is now data-driven via `Import_Manager::protocol_dispatch()` (file field, nonce, DB handler callable per protocol).
+
+`class-import-manager.php:102-106` selects the DB handler by string equality on `$action`. Refactoring to a callable map keyed by action would prevent silent fall-through if a future protocol is added.
+
+### 4.3 `wp_kses_post` may be heavier than necessary for plain notices — ⏸ left as-is
+
+All current notice messages are constant strings or sprintf-with-`esc_html` content. `wp_kses_post` is already safe in this context; the optimisation would be cosmetic. Revisit if richer notice content is added.
+
+`utils-log.php:66` renders notice messages through `wp_kses_post()`. Notices are mostly plain text with the occasional `<code>` tag; `wp_kses_post` allows a broad set of post-content tags. If notice messages will only ever contain simple inline markup, a narrower allowlist via `wp_kses` would be safer (and faster). Low priority since current usage is already escaped at call sites.
+
+### 4.4 Notice transient TTL is 60 seconds — ✅ resolved in 1.4.1
+
+Resolution: `UtilsLog::NOTICE_TTL` raised to 300 seconds.
+
+`utils-log.php:8` — if the redirect that should display the notice is delayed (slow network, reverse-proxy hiccup, user closes the browser and re-opens), the notice is dropped. 5 minutes is a more typical TTL for "post-action feedback" patterns. Drop is silent — there is no way for the user to recover the message.
+
+### 4.5 Per-tab race in `render_notices` — ⏸ accepted
+
+UX edge case; cost/benefit of a more elaborate "notices delivered" registry doesn't justify the added complexity.
+
+`utils-log.php:49-69` deletes the transient on first render. If a user has two admin tabs open and both load simultaneously after a bulk action, only one tab shows the notices. Not a correctness issue, but a slight UX surprise.
+
+### 4.6 `STUDIOU_WC_OPS_FILE` and `STUDIOU_WC_OPS_VERSION` are unused — ⏸ left as-is (see §3.6)
+
+Already listed in §3.6. Repeated here only to note the cosmetic angle: define-time effort without a reader is noise.
+
+### 4.7 No `Network:` header in the plugin file — ⏸ deferred (multisite not in scope)
+
+Carried over from §4.6 of the previous review. Only relevant for multisite installs.
+
+### 4.8 No `uninstall.php` / cleanup hook — ⏸ deferred (conscious carry-over)
+
+Carried over from §3.11 of the previous review. Order meta keys remain in the database after plugin removal. Acceptable but undocumented.
+
+### 4.9 `register_post_status` is invoked even under HPOS — ⏸ left as-is (required for WC admin chrome)
+
+`Order_Status_Manager::register_custom_order_statuses()` registers the statuses via `register_post_status`. Under HPOS this is harmless and still needed for WC admin chrome that calls `get_post_statuses()`, but the contract is slightly mixed-paradigm. No action required.
+
+### 4.10 `update_status` calls do not pass `manual = true` — ⏸ left as-is (bulk transitions are *not* manual)
+
+`bulk_set_print_status` and the import handlers call `$order->update_status($slug)` without the optional `$manual` parameter. WC distinguishes manual (user-driven) from automated transitions in its hook payloads. Currently these transitions appear as "automated" in WC reports — which is arguably correct (the bulk action *is* automated) but is a UX nuance worth conscious choice.
+
+### 4.11 WooCommerce 10.7.0 block-based order edit screen — ⏸ deferred (needs live verification)
+
+WC 10.x continues to ship the block-based / React order edit screen alongside the classic one (feature-flagged per install). `Order_Fields_Manager` hooks `woocommerce_admin_order_data_after_order_details` and `woocommerce_process_shop_order_meta`, both of which target the **classic** order edit screen. Under the block-based screen the Print Information panel does not render and edits are not saved through that pipeline. Currently this is acceptable (most production installs of WC 10.7.0 still default to the classic screen), but if Studiou opts into the block-based edit experience, a parallel data-source / slot-fill integration will be needed.
+
+---
+
+## 5. Verification gaps (against WC 10.7.0 / WP 6.9.4)
+
+The following claims in v1.4.0 rest on assumptions that could not be confirmed without an interactive test on the target stack:
+
+1. **HPOS search filter** — see §2.1. Needs confirmation that `woocommerce_order_table_search_query_meta_keys` is still emitted by WC 10.7.0.
+2. **MySQL functional-dependency recognition** for the new `GROUP BY` — see §1.1. Production MySQL/MariaDB version and `sql_mode` should be checked; WP 6.9.4 itself imposes no constraint here, the risk is the database server config.
+3. **`woocommerce_order_table_search_query_meta_keys` payload shape** — the callback assumes WC passes an indexed array of meta-key strings. If WC 10.7.0 has changed it to an associative array (key → label) or a different shape, `in_array($meta, $keys, true)` would silently no-op.
+4. **Transient delivery timing under WC 10.7.0** — `UtilsLog::message()` queues notices via a transient keyed by user ID. If a request is served while WC is invalidating user-scoped object caches (e.g. session bootstrap), the notice could be lost between queue and render. Difficult to reproduce; worth a smoke test.
+5. **Header ordering on the export response** — `nocache_headers()` precedes our `Content-Type: text/csv`. WC may emit its own headers earlier in the request via WP filters; the resulting Content-Type and Cache-Control order should be inspected with a browser inspector or `curl -I` on a WC 10.7.0 install.
+6. **Block-based order edit screen** — §4.11. WC 10.7.0 ships both classic and block-based order edit screens. The Print Information panel is wired to the classic screen only; behaviour on the block-based screen has not been verified.
+
+---
+
+## 6. Status of 1.3.2 findings
+
+| 1.3.2 finding | Status in 1.4.0 |
+|---------------|------------------|
+| §1.1 Search by External Order Number is broken | Resolved in code, **pending verification** (see §2.1 of this review). |
+| §1.2 `handle_status_transitions` writes to wrong table under HPOS | Resolved (`$order->update_meta_data() + save()`). |
+| §2.1 No CSV row deduplication | Resolved (`Studiou_DB_Manager::dedupe_by_order_no`). |
+| §2.2 CSV header casing drift | Resolved (`normalise_header`). |
+| §2.3 Bulk export non-atomic | Resolved (CSV built before status flip). |
+| §2.4 Multi-category row multiplication | Resolved via `GROUP BY` — **new risk introduced**, see §1.1. |
+| §2.5 CSV UTF-8 BOM | Resolved on export — **new asymmetry with import**, see §1.2. |
+| §2.6 Payment-complete demotion | Resolved. |
+| §2.7 Import file validation | Resolved (`validated_upload`). |
+| §2.8 Bulk handlers cap check | Resolved. |
+| §3.1 `UtilsLog::message()` dead code | Resolved (transient + `admin_notices`). |
+| §3.2 `WP_DEBUG` guard too strict | Resolved. |
+| §3.3 Asymmetric logging | Resolved (shared `bulk_set_print_status`). |
+| §3.4 No state-machine guard | Resolved (terminal-status skip). |
+| §3.5 Plugin-init order risk | Resolved (priority pinning). |
+| §3.6 SQL prepare via intval | Resolved (`$wpdb->prepare()` + `%d`). |
+| §3.7 Init-time logging volume | Resolved (init logs removed). |
+| §3.8 `parse_csv` line-length cap | Resolved (`fgetcsv(... 0 ...)`). |
+| §3.9 `external_ref_ord_date` validation | Partially resolved — see §3.5 of this review. |
+| §3.10 `external_ref_ord_date` UI exposure | Resolved. |
+| §3.11 No uninstall cleanup | Not addressed (conscious carry-over). |
+| §3.12 No version constant | Resolved (`STUDIOU_WC_OPS_VERSION`), albeit unused. |
+| §3.13 HPOS column callback parameter naming | Resolved (`$order_or_id`). |
+| §4.1 Translations incomplete | Resolved (`docs/translations.txt` extended). The `.mo` file still needs a rebuild. |
+| §4.2 README/CLAUDE.md overclaim HPOS | Resolved in wording — but contingent on §2.1 verification. |
+| §4.3 CLAUDE.md SQL prepare claim | Resolved. |
+| §4.4 `// visualized` comments | Resolved. |
+| §4.5 Bracket formatting | Resolved as a side-effect of rewriting the class. |
+| §4.6 No `Network:` header | Not addressed (carried over). |
+| §4.7 README architecture note | Cosmetic — left as-is. |
+
+Net: 26 of 30 findings closed in code; 3 carried over; 1 (search) closed but flagged as verification-pending.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by expected user-visible impact (status as of 1.4.1):
+
+1. **§1.1 Export SQL `GROUP BY` may fail on default MySQL strict mode.** ✅ resolved.
+2. **§1.2 BOM-in-imported-CSV breakage.** ✅ resolved.
+3. **§2.1 HPOS search filter unverified.** ⏸ deferred — still pending live verification on WC 10.7.0.
+4. **§2.2 Server-vs-WP timezone drift in Print Information panel.** ✅ resolved.
+5. **§3.3 `output_csv` fallback echoes raw CSV into HTML.** ✅ resolved.
+6. **§3.5 `normalise_csv_date` silently keeps bogus dates.** ✅ resolved.
+7. **§3.1 `GROUP_CONCAT` truncation.** ✅ resolved.
+8. **§3.9 PHP 8.4 deprecation noise.** ✅ resolved.
+9. **§3.7 Two order notes per payment-complete event.** ✅ resolved.
+
+---
+
+## 8. Out of scope for this review
+
+- Live behaviour testing against a running WordPress 6.9.4 / WooCommerce 10.7.0 install — every finding above is derived from source reading.
+- Performance profiling of the rewritten export query (`GROUP BY` plus four `LEFT JOIN`s) under large order volumes.
+- Behaviour of the WP admin notices renderer when WC's own admin notices are also queued in the same request.
+- Behavioural verification on the WooCommerce 10.7.0 block-based order edit screen (covered analytically in §4.11).
+- `.po` / `.mo` correctness — translations were not exercised; this review covers only their source-side coverage in `docs/translations.txt`.
+
+---
+
+*End of review.*

+ 356 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.1.md

@@ -0,0 +1,356 @@
+# Plugin Revision — v1.4.1 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.2 (2026-05-12):** every in-scope finding below has been resolved in plugin version **1.4.2**. Each finding now carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Independent read-only analytical pass over the v1.4.1 codebase. Read with deliberately fresh eyes — not just as "did the 1.4.0 fixes land", but "what would I flag if I had never seen this plugin before". Findings include items that were latent in v1.3.2 and survived both prior reviews.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap `studiou-wc-ord-print-statuses.php`, and the markdown docs.
+**Verification limitations:** No live PHP/WP/WC available. Findings rest on source reading and reasonable WP/WC behavioural assumptions for the target stack.
+
+Severity reflects user-visible or correctness impact, not implementation effort.
+
+---
+
+## 1. Critical
+
+*(None. All 1.3.2 critical findings and the new 1.4.0 criticals are resolved in 1.4.1.)*
+
+---
+
+## 2. High — newly discovered bugs and latent defects
+
+### 2.1 `Custom_Columns_Manager::format_meta_date()` applies the WP timezone offset **twice** — column display can be hours off — ✅ resolved in 1.4.2
+
+Resolution: parse the stored value with `DateTimeImmutable(..., wp_timezone())` and format via `wp_date()`. Eliminates the double-offset drift while keeping locale-aware formatting.
+
+`class-custom-columns-manager.php:50-59`:
+
+```php
+private function format_meta_date($value) {
+    if (empty($value)) { return '—'; }
+    $timestamp = strtotime($value);
+    if (!$timestamp) { return '—'; }
+    return esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $timestamp));
+}
+```
+
+Two issues compound:
+
+1. `strtotime($value)` interprets the input string in the **PHP server timezone**.
+2. `date_i18n(..., $timestamp)` formats the UNIX timestamp in the **WordPress site timezone**.
+
+`$value` is a MySQL datetime string written by `current_time('mysql')`, which is already in WP local time. The intended round-trip is: read string → display unchanged. Instead the pipeline does: parse string *as if* server-local → convert to UTC → format in WP-local. On any host where the PHP server timezone differs from the WP timezone (e.g., UTC server + `Europe/Prague` site), the displayed value drifts by the offset.
+
+**Concrete example** with server UTC, WP `Europe/Prague` (UTC+2 in summer):
+
+- Stored meta: `2026-05-12 16:30:00` (intended Prague-local, equivalent to 14:30 UTC).
+- `strtotime('2026-05-12 16:30:00')` → `1747066200` (16:30 UTC).
+- `date_i18n('Y-m-d H:i', 1747066200)` in WP-Prague → `2026-05-12 18:30`.
+- **User sees `18:30` for a record they expect to read `16:30`.**
+
+This is the same class of bug v1.4.0 review §2.2 identified for `Order_Fields_Manager`. That class was rewritten to pure string reformatting; `Custom_Columns_Manager` was missed. The fix is to convert the same way:
+
+```php
+if (preg_match('/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/', $value, $m)) {
+    return esc_html($m[1] . ' ' . $m[2]);
+}
+```
+
+…or, if locale-aware formatting is required, build a `DateTimeImmutable` with `wp_timezone()` first:
+
+```php
+$dt = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, wp_timezone());
+if ($dt) {
+    return esc_html(wp_date(get_option('date_format') . ' ' . get_option('time_format'), $dt->getTimestamp()));
+}
+```
+
+The "To Print Date" and "In Print Date" columns are the public face of this plugin in the orders list — this bug is user-visible on every page load.
+
+### 2.2 Variation attribute join uses `terms.name` while WC stores the **slug** in `attribute_pa_format` — ✅ resolved in 1.4.2
+
+Resolution: join condition changed to `product_variations_name.slug = product_variations_attr.meta_value`. The SELECT keeps `MIN(product_variations_name.name)` so the displayed value remains the human-readable name.
+
+`class-db-manager.php:60-64`:
+
+```php
+LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
+    ON `product_variations_attr`.`post_id` = `product_variations`.`ID`
+    AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
+LEFT JOIN `{$wpdb->terms}` `product_variations_name`
+    ON `product_variations_name`.`name` = `product_variations_attr`.`meta_value`
+```
+
+WC variation postmeta for attribute taxonomies (`attribute_pa_*`) stores the **term slug**, not the term name. The join is on `terms.name = meta_value`. This works only when name and slug coincide (e.g., a term where both are `"A4"`). In stores where the human-readable name diverges from the slug (e.g., `name="Velký A4"`, `slug="a4"`), the join returns nothing and `prod_var_type` is `NULL` for that line item.
+
+CLAUDE.md §"Version 1.3.2" describes the 1.3.2 fix as *"Join with wp_terms using the slug field (matching against meta_value) instead of term_id"*. The implementation uses `name`, not `slug`. This is either:
+
+- A documentation/code disagreement (CLAUDE.md is wrong about which column is used), or
+- A real latent defect that has been hidden so far because Studiou's `pa_format` terms happen to have matching name+slug.
+
+The correct join is `product_variations_name.slug = product_variations_attr.meta_value`. If display of the human-readable name (rather than the slug) is desired, leave the column as `MIN(product_variations_name.name)` but switch the join key.
+
+### 2.3 `MIN(order_products.product_qty)` silently undercounts duplicated line items — ✅ resolved in 1.4.2
+
+Resolution: replaced with `SUM(order_products.product_qty)`. A single-row group still returns the same value; duplicated line items now sum correctly.
+
+`class-db-manager.php:46-52` SELECTs:
+
+```sql
+MIN(`order_products`.`product_qty`) AS `qty`
+```
+
+…with `GROUP BY orders.id, order_products.product_id, order_products.variation_id`.
+
+`wc_order_product_lookup` is keyed per **order line item**, not per (order × product × variation). A single order can legitimately contain two line items for the same variation — most commonly when a customer adds the product, edits the cart, and re-adds the same variation rather than incrementing the quantity. WC merges these on display but the lookup table preserves the original line items.
+
+When that happens, the `GROUP BY` collapses both line items to one row and `MIN(qty)` returns one of the two quantities, *not* their sum. The print shop receives an under-quantity; some prints are missed.
+
+The semantically correct aggregation is `SUM(order_products.product_qty)`. `MIN()` is only safe when each group is guaranteed to have exactly one row — which the 1.4.1 review assumed without verifying.
+
+`SUM` over a single-row group equals that row's value, so this is a forward-compatible fix.
+
+---
+
+## 3. Medium — pre-existing issues that didn't surface in earlier reviews
+
+### 3.1 `normalise_csv_date()` formats with server timezone, breaking consistency with `current_time('mysql')` — ✅ resolved in 1.4.2
+
+Resolution: `date()` replaced with `wp_date()`. Imported `external_ref_ord_date` is now stored in WP local time, matching every other meta key.
+
+`class-db-manager.php:301-312`:
+
+```php
+$timestamp = strtotime($raw);
+…
+return date('Y-m-d H:i:s', $timestamp);
+```
+
+`date()` formats in the **server** timezone. Every other write site uses `current_time('mysql')` which is in WP timezone. The import-side `external_ref_ord_date` is therefore stored in server timezone, while values written from elsewhere are in WP timezone.
+
+Today this is hidden because:
+- The Print Information panel reads the value as an opaque string (no timezone interpretation).
+- The orders-list column has its own bug (§2.1) that adds a second offset.
+
+Once §2.1 is fixed, this asymmetry becomes user-visible. Either replace with `wp_date('Y-m-d H:i:s', $timestamp)` or construct via `DateTimeImmutable` in `wp_timezone()`.
+
+### 3.2 `wc_get_order($row['order_no'])` fails silently when the site uses non-numeric order numbers — ⏸ documented, no code change
+
+Resolution path: integrating a specific custom-order-number plugin is out of scope without knowing which one Studiou might adopt. Instead, the README now explicitly states that `order_no` in CSVs is the WC internal order ID, and the "Known limitations" section flags that custom-order-number plugins are not integrated. The export round-trip is internally consistent (the print shop echoes back what we sent).
+
+`class-db-manager.php:171, 209`. WC's `wc_get_order()` casts its argument to int when given a string. A custom-order-number plugin (e.g., "Sequential Order Numbers") that emits values like `ORD-12345` would have `wc_get_order('ORD-12345')` return `false` because `(int) 'ORD-12345' === 0`.
+
+The current Studiou install presumably does not use such a plugin, but:
+- The export query returns `orders.id` as `order_no` — the WC internal ID, **not** any displayed/custom order number.
+- The print provider then echoes back what they received, so the round-trip is consistent.
+- But this is undocumented. README §"CSV formats" calls the column `order_no` without clarifying that it is the WC order ID, not the customer-facing order number.
+
+Risk: when Studiou eventually installs a sequential-numbers plugin and customer-facing order numbers diverge from IDs, the import contract silently breaks.
+
+Suggested mitigation: document the contract; optionally support lookup-by-meta-key as a fallback (`wc_orders_table_query`/`wc_get_orders` with a meta query on the custom-number meta key).
+
+### 3.3 `INNER JOIN` on `product_variations` excludes orders containing simple (non-variable) products — ✅ resolved in 1.4.2
+
+Resolution: changed to `LEFT JOIN`. Simple products now appear in the export with blank `prod_var`/`prod_var_type`. README "Known limitations" calls out the implication.
+
+`class-db-manager.php:58-59`:
+
+```sql
+JOIN `{$wpdb->posts}` `product_variations`
+    ON `product_variations`.`ID` = `order_products`.`variation_id`
+```
+
+When the order line item is a non-variable product, `variation_id = 0` and there is no matching `posts` row. The `JOIN` (inner) eliminates that row entirely.
+
+If the print shop sometimes ships products that aren't configured as variations in WC, those products vanish from the CSV — silently. The plugin appears designed only for variable products (`prod_var`, `prod_var_type` columns), so this is *intentional* but undocumented. A reviewer auditing this code for the first time would not be able to tell.
+
+Either convert to `LEFT JOIN` and `COALESCE(product_variations.post_title, products.post_title)`, or document the design constraint explicitly.
+
+### 3.4 `SET SESSION group_concat_max_len = 65535` leaks to other queries on the same connection — ✅ resolved in 1.4.2
+
+Resolution: switched to `SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)` — never lowers a higher pre-existing value, only raises it to at least 64 KB.
+
+`class-db-manager.php:37`. `SET SESSION` is connection-scoped. In WP's default (non-persistent) MySQL connections, the connection lifetime equals the request lifetime, so the side effect is bounded. With a persistent connection pool (`mysqli.allow_persistent=1` plus a managed pool), the modified value carries into other requests reusing the same connection. Highly unlikely to break anything (the limit only goes up), but worth knowing.
+
+A cleaner pattern is `$wpdb->query("SET @@SESSION.group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)")` or wrapping the export in `SET …; SELECT …; SET @@SESSION.group_concat_max_len = DEFAULT;`. Cost/benefit: cosmetic for the current deployment.
+
+### 3.5 `images.guid` is used as the image URL, but WP's codex explicitly advises against it — ✅ resolved in 1.4.2
+
+Resolution: the export now selects the `_thumbnail_id` and resolves URLs via `wp_get_attachment_url()` in PHP after the query. The `wp_posts` join on the attachment table was dropped. Offloaded-media plugins, multisite uploads paths, and the `wp_get_attachment_url` filter chain are now respected.
+
+`class-db-manager.php:50, 68-70` selects and joins `wp_posts.guid` for the variant thumbnail URL. The WordPress codex documents `guid` as "not necessarily a URL" and warns against using it for that purpose. The canonical lookup is `wp_get_attachment_url($attachment_id)`, which respects:
+
+- Multisite per-blog uploads paths
+- Filters (`wp_get_attachment_url`, `upload_dir`)
+- Image stored in S3 / offloaded media plugins
+
+Pure SQL can't call `wp_get_attachment_url`. The pragmatic alternative is to select the `_thumbnail_id` (already joined as `product_meta.meta_value`) and resolve URLs in PHP for each row before sending the CSV. The cost is one PHP call per line item — negligible for export-volume row counts.
+
+This is a pre-existing concern from v1.3.2 that none of the earlier reviews surfaced.
+
+### 3.6 Server-side file extension / MIME check missing on imports — ✅ resolved in 1.4.2 (extension)
+
+Resolution: `validated_upload()` now checks `pathinfo($file['name'], PATHINFO_EXTENSION) === 'csv'` after the existing `$_FILES[...]['error']` and `is_uploaded_file()` checks. A stricter MIME-type sniff was considered overkill given that `parse_csv` itself only reads bytes — no execution path exists for an uploaded file.
+
+`class-import-manager.php:134-156` validates `$_FILES[...]['error']` and `is_uploaded_file()`, but does not check the file extension or MIME type. The `accept=".csv,text/csv"` HTML attribute is client-side only.
+
+Today this is not exploitable: `parse_csv` simply reads bytes through `fgetcsv` and returns rows. A misnamed binary file produces an empty parse result and the "could not be parsed" notice. But defense-in-depth would be:
+
+```php
+$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+if ($ext !== 'csv') { /* notice + abort */ }
+```
+
+Cheap and good hygiene.
+
+### 3.7 `wc_order_product_lookup` dependency is implicit — ✅ resolved in 1.4.2
+
+Resolution: `get_orders_for_printing()` now pre-checks the table with `SHOW TABLES LIKE …` and, if it is missing, emits a clear admin notice ("Export requires the WooCommerce analytics lookup table. Enable WooCommerce Analytics or contact support.") and returns an empty result instead of letting the SQL error surface obliquely.
+
+`class-db-manager.php:53-54` joins `{$wpdb->prefix}wc_order_product_lookup`. This table is part of WC analytics and is created by WC by default — but it can be absent on:
+
+- Very old WC installs that pre-date the analytics module
+- Installs where the user explicitly disabled the WC analytics feature (it can be turned off in WC 4.0+)
+- Newly migrated databases where the lookup-table sync hasn't run yet
+
+When the table is missing the export query fails with a SQL error; the failure is logged and the bulk action emits "no exportable items" but doesn't tell the operator *why*. Either pre-check `$wpdb->get_var("SHOW TABLES LIKE '{$wpdb->prefix}wc_order_product_lookup'")` once at boot, or surface the SQL error message in the warning notice when it is "table doesn't exist".
+
+### 3.8 HPOS-search hook name `woocommerce_order_table_search_query_meta_keys` is still unverified against WC 10.7.0 — ⏸ deferred (verification task)
+
+Same status as in the v1.4.0 review — no live verification possible from this environment. README "Known limitations" surfaces this so operators can confirm against the running site.
+
+Carried over from `revise-1.4.0.md` §2.1. No live WC available; the filter name has not been visually confirmed in WC 10.7.0 source. If the filter has been renamed, the legacy `woocommerce_shop_order_search_fields` will still keep the feature working under legacy CPT, but HPOS users will lose it again.
+
+This remains the single most important verification ask before release-tagging 1.4.1.
+
+---
+
+## 4. Low — style and minor
+
+### 4.1 `parse_csv` uses error-suppression `@fopen` — ✅ resolved in 1.4.2
+
+Resolution: `fopen()` is now called without `@`; failures are logged via `UtilsLog::log()`.
+
+`class-db-manager.php:234` — `@fopen()` swallows the underlying warning. Replace with `fopen($path, 'r')` plus explicit error logging on `false`, and add a `clearstatcache()` if relevant. WordPress's general style is to avoid `@`.
+
+### 4.2 `STUDIOU_WC_OPS_VERSION` / `STUDIOU_WC_OPS_FILE` remain defined but unused inside the plugin — ⏸ left as-is
+
+`studiou-wc-ord-print-statuses.php:22-23`. Acknowledged in `revise-1.4.0.md` §3.6 / §4.6 — left in place for external consumers. No action.
+
+### 4.3 `handle_status_transitions` doesn't clear meta on transition *out* of a print status — ⏸ accepted as designed
+
+`class-order-status-manager.php:79-94` stamps `to_print_date` / `in_print_date` on entry, but never clears them on exit (e.g., to-print → cancelled). The historical timestamp is preserved — usually desired. Worth documenting as the deliberate design choice if a future contributor wonders.
+
+### 4.4 `woocommerce_process_shop_order_meta` hook coverage under HPOS — ⏸ left as-is (compat shim still present in WC 10.7.0)
+
+`class-order-fields-manager.php:31` hooks `woocommerce_process_shop_order_meta`. WC 8.x added a compatibility shim that fires this hook on HPOS as well, but the canonical HPOS save hook is `woocommerce_after_order_object_save`. If WC ever deprecates the compatibility shim, manual edits to the Print Information panel will stop persisting on HPOS-only setups. Worth a forward-looking note.
+
+### 4.5 `private $custom_statuses` initialised lazily but `=== null` check is fragile under any future strict-type migration — ⏸ left as-is (no typed-property refactor planned)
+
+`class-order-status-manager.php:7, 17-18`. If the property is later typed as `array $custom_statuses` (PHP 7.4 typed properties), an uninitialised typed property emits an `Error` on read — `=== null` would never reach. Cosmetic concern; flag if/when typed-properties refactor happens.
+
+### 4.6 `generate_csv()` reads `(array) $data[0]` without re-guarding — ⏸ left as-is (caller guarantees non-empty)
+
+`class-bulk-actions-manager.php:113`. Defensive: caller already gates with `!empty($orders_data)`, so this is safe today. But the function would crash on direct invocation with an empty array. A one-line `if (empty($data)) return '';` would make it self-contained.
+
+### 4.7 Two `__('Import Protocols', …)` calls produce identical translatable strings — ✅ resolved in 1.4.2
+
+Resolution: cached in a local `$title` variable before the `add_submenu_page` call.
+
+`class-import-manager.php:16-17` translates the same string twice (menu title and page title). Functionally identical, two lookups instead of one. Cosmetic.
+
+### 4.8 `register_post_status` still called under HPOS — ⏸ left as-is
+
+Carried over from `revise-1.4.0.md` §4.9. Harmless duplication, kept for WC admin chrome compatibility.
+
+### 4.9 `add_custom_order_statuses_to_list` silently no-ops when `wc-processing` is absent — ✅ resolved in 1.4.2
+
+Resolution: an `$inserted` flag tracks whether the anchor was found; the fallback path appends the custom statuses at the end of the dropdown if not.
+
+`class-order-status-manager.php:54-65`. If a third party removes `wc-processing` from `wc_order_statuses`, our statuses are never inserted (the foreach finds no anchor). Highly unlikely in practice; a defensive fallback that appends at the end if `wc-processing` is missing would tighten this.
+
+---
+
+## 5. Documentation drift
+
+### 5.1 CLAUDE.md describes the `attribute_pa_format` join as using `slug`; the code uses `name` — ✅ resolved in 1.4.2
+
+Resolution: the code was changed to use `slug` (§2.2); CLAUDE.md's description now matches reality. The 1.3.2 changelog entry there gained a footnote pointing at this review.
+
+CLAUDE.md §"Version 1.3.2" `Recent Changes` paragraph says:
+
+> Join with `wp_terms` using the slug field (matching against meta_value) instead of term_id
+
+The actual SQL in `class-db-manager.php:64` joins on `terms.name = meta_value`. See §2.2 above — either fix the code or fix the doc, but the two should agree.
+
+### 5.2 README's CSV-import section doesn't define what `order_no` means — ✅ resolved in 1.4.2
+
+Resolution: a callout above the CSV-format section explicitly states `order_no` is the WooCommerce internal order ID and describes the interaction with custom-order-number plugins.
+
+§3.2 above. README should explicitly say `order_no` is the WC internal order ID, and explain the interaction (or lack thereof) with custom-order-number plugins.
+
+### 5.3 Deferred items in `revise-1.4.0.md` should appear in the project's "Known limitations" section of README — ✅ resolved in 1.4.2
+
+Resolution: README has a new "Known limitations" section listing HPOS search-filter verification, variable-products-only design, WC Analytics dependency, block-based order edit screen, no multisite header, no uninstall cleanup, custom-order-number plugin gap, hardcoded `attribute_pa_format`, and the `group_concat_max_len` session-scoped bump.
+
+Currently the README changelog mentions them only in passing under 1.4.1. New readers of the README don't get the same heads-up that a contributor reading the revise doc does. A short "Known limitations" section in the README would consolidate:
+
+- HPOS search filter verification still pending on WC 10.7.0
+- No multisite `Network:` header
+- No uninstall cleanup
+- Block-based order edit screen unsupported
+- Optimised for variable products only (§3.3)
+- Reliance on WC analytics' `wc_order_product_lookup` table (§3.7)
+
+---
+
+## 6. Spec compliance recap (against `docs/instructions.txt`)
+
+| Spec item | Status as of 1.4.1 |
+|-----------|--------------------|
+| 1. Custom statuses after `wc-processing` | ✅ |
+| 2. Custom order fields | ✅ (plus `external_ref_ord_date` extension) |
+| 3. Search by `external-ref-ord-no` | ⏸ resolved in code, HPOS filter name pending verification |
+| 4. Three bulk actions | ✅ |
+| 5. Three custom columns | ✅ (display bug §2.1 affects the date columns) |
+| 6. Import InPrint with DISTINCT | ✅ |
+| 7. Import Delivered with DISTINCT | ✅ |
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.2**:
+
+1. **§2.1 Column-display timezone double-shift.** ✅ resolved.
+2. **§2.3 `MIN(qty)` undercount with duplicate line items.** ✅ resolved (`SUM(qty)`).
+3. **§2.2 Variation join on `terms.name` instead of `terms.slug`.** ✅ resolved.
+4. **§3.1 `normalise_csv_date` server-timezone vs WP-timezone asymmetry.** ✅ resolved (`wp_date()`).
+5. **§3.8 HPOS search filter unverified.** ⏸ deferred — still pending live verification on WC 10.7.0.
+6. **§3.5 `images.guid` as the image URL.** ✅ resolved (`wp_get_attachment_url()`).
+7. **§3.7 Implicit `wc_order_product_lookup` dependency.** ✅ resolved (pre-flight `SHOW TABLES`).
+8. **§3.3 Non-variable products silently absent from export.** ✅ resolved (`LEFT JOIN`).
+9. **§3.2 `wc_get_order($row['order_no'])` for non-integer order numbers.** ⏸ documented, no code change.
+10. **§3.6 No server-side CSV extension/MIME check.** ✅ resolved (extension only).
+
+---
+
+## 8. Resolution status of prior reviews
+
+- **`revise-1.3.2.md`:** 26 of 30 findings resolved in 1.4.0. 3 deferred (no uninstall, no `Network:`, cosmetic README). 1 (HPOS search) resolved in code, verification pending. **No regressions detected.**
+- **`revise-1.4.0.md`:** all in-scope findings resolved in 1.4.1 per its own per-item status markers. **One regression remained latent — see §2.1 of this review.** §2.2 was the documented Order_Fields fix; the parallel issue in Custom_Columns_Manager was missed.
+
+---
+
+## 9. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling of the export query on large order volumes (the GROUP BY + 4× LEFT JOIN is non-trivial; needs EXPLAIN against production data).
+- Behavioural verification on the WC 10.7.0 block-based order edit screen.
+- `.po`/`.mo` correctness — translations were verified for source-side coverage only.
+
+---
+
+*End of review.*

+ 328 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.2.md

@@ -0,0 +1,328 @@
+# Plugin Revision — v1.4.2 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.3 (2026-05-12):** every in-scope finding below has been resolved in plugin version **1.4.3**. Each finding now carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Independent read-only analytical pass over the v1.4.2 codebase. Re-read all eight PHP files from scratch and looked for issues regardless of whether they'd appeared in earlier reviews. This time I deliberately compared paired code paths (the two import handlers, the two cap-checks, the listener-vs-bulk stamps) and walked through user scenarios that the previous reviews didn't exercise.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap `studiou-wc-ord-print-statuses.php`, and the markdown docs.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact, not implementation effort.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High
+
+### 2.1 `import_delivered_protocol` does not skip terminal-status orders — asymmetry with `import_inprint_protocol` — ✅ resolved in 1.4.3
+
+Resolution: added a `has_status(['cancelled', 'refunded', 'failed'])` guard with the same per-row error and `UtilsLog::log` call as the InPrint path. `completed` is intentionally excluded — re-running a delivered protocol on already-completed orders is a benign no-op.
+
+`class-db-manager.php:200-230` (InPrint) and `:238-258` (Delivered).
+
+The InPrint import explicitly skips orders in `cancelled`, `refunded`, `failed`, or `completed` states:
+
+```php
+if ($order->has_status(self::TERMINAL_STATUSES)) {
+    $errors[] = sprintf(__('Order %s is in a final state and was not updated.', …), $row['order_no']);
+    continue;
+}
+$order->update_status('in-print');
+```
+
+The Delivered import does not:
+
+```php
+$order = wc_get_order($row['order_no']);
+if (!$order) { /* ... */ continue; }
+// ← no terminal-status guard here
+$order->update_status('completed');
+$order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
+```
+
+**Concrete failure modes:**
+
+- A `cancelled` order that appears in the print shop's delivered CSV (operator error, stale data, ghost entry) is silently **reactivated to `completed`**.
+- A `refunded` order is reactivated to `completed`. The refund accounting in WC is now inconsistent with order state.
+- A `failed` order is reactivated to `completed`. Same problem.
+
+Whether the asymmetry is intentional cannot be inferred from the spec (`docs/instructions.txt` line 32-36 does not address terminal states). It looks accidental — the cancellation paths in `bulk_set_print_status` and `import_inprint_protocol` are clearly defensive, and `import_delivered_protocol` was implemented to a parallel template but the guard wasn't ported.
+
+**Suggested fix:** mirror the InPrint guard. Optionally skip `completed` from the list since `completed → completed` is a no-op transition anyway and operators may legitimately re-deliver the same protocol.
+
+### 2.2 Import handlers require `manage_woocommerce`, bulk actions require `edit_shop_orders` — shop managers cannot use the import screen — ✅ resolved in 1.4.3
+
+Resolution: both `add_submenu_page()` and the `current_user_can` check in `run_import()` now use `edit_shop_orders`. Shop Managers see and can use the Import Protocols screen, matching the bulk-action capability requirement.
+
+`class-import-manager.php:106` (`current_user_can('manage_woocommerce')`) vs `class-bulk-actions-manager.php:28` (`current_user_can('edit_shop_orders')`).
+
+WC's default capabilities:
+- The **Shop Manager** role grants `edit_shop_orders` (and most order-management caps) **but not** `manage_woocommerce`.
+- `manage_woocommerce` is the WC administrator cap, normally given only to the Administrator role.
+
+So a Studiou employee with the Shop Manager role can:
+- Run the three bulk actions (`current_user_can('edit_shop_orders')` ✓).
+- Edit the Print Information panel on the order edit screen (`current_user_can('edit_shop_order', $order_id) || 'edit_shop_orders'` ✓).
+
+But they cannot:
+- Open **WooCommerce → Import Protocols** (`manage_woocommerce` ✗) — `add_submenu_page` hides the page itself, and `wp_die` blocks the POST handler.
+
+For day-to-day print-shop ops this means every protocol import has to go through an administrator, which is almost certainly not intended. Either:
+
+- Lower the import requirement to `edit_shop_orders` to match the bulk-action requirement, **or**
+- Raise the bulk-action requirement to `manage_woocommerce` and document the policy intent.
+
+The first option is the operationally pragmatic one; the second would be the more locked-down choice.
+
+---
+
+## 3. Medium
+
+### 3.1 `table_exists($table)` doesn't escape MySQL LIKE wildcards in `$wpdb->prefix` — ✅ resolved in 1.4.3
+
+Resolution: `$wpdb->esc_like($table)` is applied before `$wpdb->prepare()`. The post-query equality check stays as a belt-and-braces safeguard.
+
+`class-db-manager.php:128-132`:
+
+```php
+private static function table_exists($table) {
+    global $wpdb;
+    $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
+    return $found === $table;
+}
+```
+
+`$wpdb->prepare(..., '%s')` quotes the string for SQL, but `SHOW TABLES LIKE` interprets the value as a LIKE pattern where `_` is a single-character wildcard and `%` is a multi-character wildcard. `$wpdb->prefix` is conventionally alphanumeric + underscore (e.g., `wp_`, `wp_test_`), so the underscores in the prefix become wildcards.
+
+For prefix `wp_test_`, the LIKE pattern `wp_test_wc_order_product_lookup` matches:
+
+- `wp_test_wc_order_product_lookup` ✓ (intended)
+- `wpXtestXwcXorderXproductXlookup` (each `_` matches any char)
+- `wp1test2wc3order4product5lookup`
+
+The strict `=== $table` comparison after the query mitigates false positives — if a wildcard match returns a different table name, the equality check rejects it. **So today this is safe**, but the safety relies on the post-query comparison rather than on the query itself returning at most the intended row. Two ways the post-comparison could miss:
+
+1. If two tables happen to match the pattern and one is `wp_test_wc_order_product_lookup` exactly, MySQL returns multiple rows; `$wpdb->get_var` returns the first one. Order is engine-dependent.
+2. If a future contributor moves `table_exists` to a context where multiple matches matter (e.g., a `tables_like` helper), the wildcard semantics will silently misbehave.
+
+**Suggested fix:**
+
+```php
+$found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
+```
+
+`$wpdb->esc_like()` escapes `%` and `_` for LIKE pattern use.
+
+### 3.2 `set_order_processing_status` is effectively dead code on modern WC — ✅ resolved in 1.4.3
+
+Resolution: removed the handler and its `woocommerce_payment_complete` hook registration. The corresponding translation entry in `docs/translations.txt` was removed too. If a future custom payment gateway is found to fire the action without first setting the status, the handler can be reintroduced with an explanatory comment.
+
+`class-order-status-manager.php:112-129`. The handler is hooked to `woocommerce_payment_complete`, which is fired by `WC_Order::payment_complete()`. WC's own implementation of `payment_complete()`:
+
+1. Determines the destination status based on order content (downloadable-only → `completed`, otherwise → `processing`).
+2. Sets the order to that status **before** firing the action.
+
+When our handler then runs:
+
+- If WC set `processing`, our `update_status('processing')` is a no-op (same-status, WC short-circuits inside `set_status`).
+- If WC set `completed` (downloadable-only orders), our `has_status(['completed', 'to-print', 'in-print'])` guard hits → we return.
+
+In both cases the handler does nothing. It would only matter if a custom payment gateway fires `woocommerce_payment_complete` without first setting the order status — which is non-standard and arguably the gateway's bug.
+
+Two options:
+
+- **Remove it.** The hook serves no purpose on a default WC installation.
+- **Keep it and document.** Add a comment explaining what scenario it defends against (legacy gateway behaviour from pre-WC-4.0 days?). Otherwise a future reader will be confused.
+
+The order note string (`"Payment received — order automatically set to processing by …"`) appears in `docs/translations.txt` and is shipped translated — if the code is removed, the translation entry should follow it.
+
+### 3.3 CSV injection — output values may trigger Excel formula execution — ✅ resolved in 1.4.3
+
+Resolution: `Studiou_DB_Manager::csv_escape()` is applied to every cell value in the PHP post-processing loop. Cells beginning with `=`, `+`, `-`, `@`, `\t`, or `\r` get a leading single quote — hidden by Excel/LibreOffice display, prevents formula evaluation.
+
+`class-bulk-actions-manager.php:113-115` writes CSV cells directly from the export-query result. CSV cells beginning with `=`, `+`, `-`, `@`, or `\t` are interpreted by Excel and LibreOffice Calc as formulas. If a customer's `billing_email`, product name, or category name happens to begin with one of these characters — accidentally or maliciously — the print shop opens the CSV in Excel and a formula executes.
+
+Realistic vectors:
+
+- A customer registers `=cmd|'/c calc'!A1` as their email. The export includes it as-is. Excel parses it as a formula.
+- A product category gets renamed to `=HYPERLINK("https://evil/", "click")` by a compromised admin or contractor. The export embeds it; opening the CSV silently navigates the cell-link target.
+
+The mitigation in WP-aware code is to prefix any cell value starting with a dangerous character with a leading single-quote (`'`) or with a leading `\t`-equivalent zero-width-width control. The OWASP-recommended pattern is the leading single-quote:
+
+```php
+private function csv_escape($value) {
+    $value = (string) $value;
+    if ($value !== '' && in_array($value[0], array('=', '+', '-', '@', "\t", "\r"), true)) {
+        return "'" . $value;
+    }
+    return $value;
+}
+```
+
+Apply this to every cell before passing it to `fputcsv`. Excel still shows the value (without the leading quote) but does not evaluate it.
+
+The risk is **low likelihood, real impact** — print shop spreadsheet users are a concrete attack surface for this class of vulnerability.
+
+### 3.4 `datetime_local_to_mysql` accepts structurally-valid but semantically-out-of-range values — ✅ resolved in 1.4.3
+
+Resolution: `checkdate()` validates the date components; explicit numeric range checks reject hour > 23, minute > 59, second > 59. Bogus values return empty string instead of being stored.
+
+`class-order-fields-manager.php:108-118`:
+
+```php
+if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?$/', $value, $m)) {
+    $seconds = isset($m[3]) ? $m[3] : '00';
+    return $m[1] . ' ' . $m[2] . ':' . $seconds;
+}
+```
+
+The regex matches `2026-13-45T25:99` as well as `2026-05-12T14:30`. The function then stores `2026-13-45 25:99:00` to postmeta. MySQL's behaviour for an out-of-range datetime depends on `sql_mode`:
+
+- Strict mode: rejects the value, raises an error.
+- Non-strict mode: silently coerces to `0000-00-00 00:00:00`.
+
+Browsers' native `<input type="datetime-local">` validate the value before submit, so this is unlikely to come from a normal browser flow. But:
+
+- A power user editing the form via DevTools, or
+- A script POSTing directly to the order edit URL,
+
+…can produce the bogus value. The downstream display (`mysql_to_datetime_local`) would reject the bogus stored value (the back-conversion regex would still match structurally but the data is meaningless) and show an empty field.
+
+**Suggested fix:** validate via `checkdate()` and seconds/minutes/hours range before composing the MySQL string. Defense-in-depth.
+
+---
+
+## 4. Low
+
+### 4.1 CSV uses `,` separator — Excel CS-locale users see single-column data — ⏸ documented, no code change
+
+Resolution path: a `sep=,\n` directive at the top of the file (the Microsoft proprietary extension Excel respects) would break the export-then-reimport workflow because CSV parsers would see `sep=,` as the first header row. The README "Known limitations" section now documents the workaround (use Excel's Text Import wizard or change Windows regional list-separator).
+
+The plugin exports with comma-separated values. The Czech edition of Excel defaults to `;` as the list separator (because comma is the decimal separator), so a Czech operator opening the file in Excel without changing the locale or pre-converting sees every row as one cell.
+
+LibreOffice Calc prompts for the separator on import; Excel-CS does not. Workarounds the operator can apply: rename the file extension to `.txt` and use Excel's text-import wizard, or change Windows regional settings. Neither is ideal.
+
+Options:
+
+- Add a `\xEF\xBB\xBFsep=,\n` directive at the very top of the CSV (Microsoft proprietary extension; understood by Excel).
+- Make the separator a settable plugin option.
+- Document the locale workaround.
+
+### 4.2 `SUM(order_products.product_qty)` returns DECIMAL — CSV shows `2.0000` for integer quantities — ✅ resolved in 1.4.3
+
+Resolution: `Studiou_DB_Manager::format_quantity()` normalises whole numbers to integer string (`"2"`) and trims trailing zeros from decimals (`"2.5"` for `"2.5000"`).
+
+`wc_order_product_lookup.product_qty` is `DECIMAL(8,4)` in the WC schema. `SUM()` of `DECIMAL` returns `DECIMAL`. `$wpdb->get_results()` returns strings. The CSV row therefore contains `2.0000` for an order of two items, not `2`.
+
+The print shop's downstream tooling may or may not care. Cosmetic but minor friction.
+
+**Suggested fix:** in the PHP post-processing step that already resolves `prod_img_url`, also normalise `$row->qty` — if the value is a whole number, drop trailing zeros and the decimal point.
+
+### 4.3 `import_delivered_protocol::update_status('completed')` doesn't pass a note — ✅ resolved in 1.4.3
+
+Resolution: both import handlers now pass an explanatory note as the second argument to `update_status` — "Marked In Printing by InPrint Protocol import." and "Marked completed by Delivered Protocol import." The order activity feed reflects how the transition was triggered.
+
+`class-db-manager.php:252` — no second argument. WC writes a default-styled order note ("Order status changed from … to completed."). For traceability, passing a note like `"Marked completed by Delivered Protocol import."` would distinguish protocol-driven completions from manual ones. The InPrint and bulk paths similarly don't pass notes; an opportunity to add operational context across the board.
+
+### 4.4 Import handlers don't log per-row to `UtilsLog::log` — ✅ resolved in 1.4.3
+
+Resolution: both import handlers now write `UtilsLog::log` entries for not-found orders, terminal-status skips, and per-row success — symmetric with `bulk_set_print_status`.
+
+`bulk_set_print_status` logs per-order success (`Order #N marked as "to-print"`) and per-order skip. The two import handlers log only at the bulk-action-level (via `UtilsLog::message`); they don't write to `error_log` per row. When debugging a failed import, the operator/developer cannot see which orders were touched and which weren't.
+
+Asymmetric with the bulk path; consistency would help.
+
+### 4.5 `Order_Status_Manager::set_order_processing_status` has no audit trail of why it was a no-op — ✅ resolved by removal in 1.4.3 (see §3.2)
+
+When `set_order_processing_status` skips because of the protected-status guard (`completed`, `to-print`, `in-print`), it returns silently. Adding `UtilsLog::log` would help debug "why didn't payment_complete advance my order to processing?" support questions.
+
+Related to §3.2 — if the handler is kept as-is, instrument it; if removed, this is moot.
+
+### 4.6 `array_filter` without callback removes order ID 0 — ✅ resolved in 1.4.3
+
+Resolution: `array_filter` now uses an explicit `function ($id) { return $id > 0; }` callback. Intent is unambiguous in the source.
+
+`class-db-manager.php:42` — `array_filter(array_map('intval', (array) $order_ids))`. `array_filter` with no callback drops all falsy values, including integer `0`. WC orders cannot have ID 0 in practice, so this is harmless, but it's slightly misleading — a future contributor adapting this for `WP_Term` IDs or anything where 0 is a legitimate value would inherit the bug.
+
+Cosmetic.
+
+### 4.7 Trashed products still appear in the export — ✅ documented in README "Known limitations"
+
+Resolution path: this is intentional — historical orders should reflect what was sold. The README "Known limitations" section now makes that explicit. No code change.
+
+The `JOIN posts products ON products.ID = order_products.product_id` doesn't filter on `post_status`. A product whose post has been moved to trash (or set to draft) still produces a CSV row for any historical order line item. The print shop sees the product name as it was at the time of the trash, not what the customer originally saw.
+
+Probably intended — historical orders should reflect what was sold, not the current product state — but worth documenting.
+
+### 4.8 `import_inprint_protocol` and `import_delivered_protocol` emit one `Invalid row format in CSV.` error per malformed row — ✅ resolved in 1.4.3
+
+Resolution: `Import_Manager::emit_import_summary()` runs `array_unique()` on the error list before formatting the notice. The numeric total still reflects the full error count.
+
+`class-db-manager.php:206, 244`. If a CSV has 100 malformed rows, the error list has 100 identical strings. The Import Manager's `emit_import_summary` joins them with `, ` and shows the count — but the joined string can become very long. A simple `array_unique` on the errors list or a per-error-kind counter would tighten the UX.
+
+### 4.9 `wp_kses_post()` allows `<a href>` — admin notice content could include arbitrary links — ✅ resolved in 1.4.3
+
+Resolution: `UtilsLog::render_notices()` now uses `esc_html()` for the notice body. All current callers pass plain text; future callers wanting markup will need to pre-format with their own escape strategy.
+
+`utils-log.php:66`. None of the current call sites pass HTML containing user-controlled links, but the `message()` API doesn't restrict what callers can pass. If a future caller passes a partially-escaped string with a user-controlled URL, `wp_kses_post` would allow `<a href="...">` through. The current set of callers builds notices from sprintf-of-translated-strings plus `esc_html`'d data, so no live risk — but the trust boundary is the API not the call sites.
+
+A narrower allowlist via `wp_kses($html, array())` for plain-text-only or `wp_kses($html, array('code' => array(), 'strong' => array(), 'em' => array()))` would be defense-in-depth.
+
+### 4.10 `register_post_status` keeps being called even though HPOS doesn't read it — ⏸ left as-is
+
+Carried over from `revise-1.4.1.md` §4.8 — harmless under HPOS, kept for admin-chrome compatibility. No change.
+
+---
+
+## 5. Verification gaps (carried over from earlier reviews)
+
+1. **HPOS search filter name** `woocommerce_order_table_search_query_meta_keys` against WC 10.7.0 source — still pending live verification.
+2. **`woocommerce_process_shop_order_meta`** hook behaviour under WC 10.7.0 block-based order edit screen — pending live verification.
+3. **Object-cache scenarios for `UtilsLog::message`** — pending live verification under Redis/Memcached configurations.
+
+---
+
+## 6. Status of prior reviews
+
+| Review | Status of in-scope findings | Status of deferred findings |
+|--------|------------------------------|------------------------------|
+| `revise-1.3.2.md` | All resolved in 1.4.0 | 3 still deferred. |
+| `revise-1.4.0.md` | All resolved in 1.4.1 | 1 verification-pending (HPOS filter). |
+| `revise-1.4.1.md` | All resolved in 1.4.2 | 2 documented-without-code-change. |
+
+No regressions detected. The new findings in this review (§2.1, §2.2) are pre-existing defects that survived three prior reviews because the paired code paths were never compared side-by-side.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.3**:
+
+1. **§2.1 Delivered import reactivates cancelled/refunded orders to `completed`.** ✅ resolved.
+2. **§2.2 Shop managers cannot run protocol imports.** ✅ resolved.
+3. **§3.3 CSV injection in exports.** ✅ resolved.
+4. **§3.1 `SHOW TABLES LIKE` wildcard escape.** ✅ resolved.
+5. **§3.2 `set_order_processing_status` is dead code on modern WC.** ✅ resolved (removed).
+6. **§3.4 `datetime_local_to_mysql` accepts out-of-range structurally-valid values.** ✅ resolved.
+7. **§4.1 CSV `,` separator vs Czech Excel locale.** ⏸ documented in README "Known limitations".
+8. **§4.2 `SUM(qty)` produces `2.0000` not `2`.** ✅ resolved.
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling of the export query under realistic order volumes.
+- Refactoring the manager pattern into typed DI / interfaces (PHP 7.4+).
+- Multisite, uninstall cleanup, block-based order edit screen (carried-over deferrals).
+
+---
+
+*End of review.*

+ 267 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.3.md

@@ -0,0 +1,267 @@
+# Plugin Revision — v1.4.3 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.4 (2026-05-12):** every in-scope finding below has been resolved in plugin version **1.4.4**. Each finding carries an inline status marker — ✅ resolved, ⏸ deliberately deferred. See the README changelog for the corresponding code changes.
+
+**Scope:** Independent read-only analytical pass over the v1.4.3 codebase. Re-read every PHP file from scratch and walked through user scenarios with deliberate scepticism toward fixes I made in the 1.4.2 / 1.4.3 cycles. The intent of this pass was specifically to look for *regressions introduced by recent fixes* rather than longer-standing issues.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap `studiou-wc-ord-print-statuses.php`, the markdown docs, and the refreshed `.po` translation source.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High — newly discovered, introduced by recent fix-ups
+
+### 2.1 `Studiou_DB_Manager::normalise_csv_date()` shifts timezone-less CSV dates by the WP timezone offset — ✅ resolved in 1.4.4
+
+Resolution: `strtotime` removed entirely. Fast path uses pure-regex reformat for ISO-like inputs (no timezone shift). Fallback uses `DateTimeImmutable($raw, wp_timezone())->setTimezone(wp_timezone())` — interprets timezone-less input as WP local, honours explicit timezones, throws on garbage. The structural fix recommended in the review's §9 (zero `strtotime` calls in `includes/`) is in effect.
+
+`class-db-manager.php:426-437`:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') {
+        return '';
+    }
+    $timestamp = strtotime($raw);
+    if (!$timestamp) {
+        UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+        return '';
+    }
+    return wp_date('Y-m-d H:i:s', $timestamp);
+}
+```
+
+This was changed from `date(...)` to `wp_date(...)` in 1.4.2 with the stated intent of "matching `current_time('mysql')` elsewhere". **The change introduced a timezone shift bug.** Trace:
+
+WordPress calls `date_default_timezone_set('UTC')` very early in its boot. So `strtotime($raw)` for a timezone-less input string interprets it as UTC. Then `wp_date($fmt, $timestamp)` treats its `$timestamp` argument as a UTC value and formats it in the WP site timezone.
+
+Concrete walkthrough with WP site timezone `Europe/Prague` (UTC+2 in summer):
+
+1. CSV `externalorderdate` cell: `"2026-05-12 14:30"` — the print shop wrote this intending Prague-local 14:30.
+2. `strtotime("2026-05-12 14:30")` in WP's UTC-default PHP context → timestamp T (representing 14:30 UTC).
+3. `wp_date('Y-m-d H:i:s', T)` → formatted in Prague timezone → `"2026-05-12 16:30:00"`.
+4. Stored to meta. Operator opening the order edit screen sees **16:30**. CSV said 14:30. **2-hour drift.**
+
+The same direction-of-drift bug as 1.4.0 §2.2 / 1.4.1 §2.1, just in a different function. The previous fix-pass made `Order_Fields_Manager::mysql_to_datetime_local` and `datetime_local_to_mysql` use pure string reformatting (no `strtotime`/`date`/`wp_date`) precisely to avoid this. `Custom_Columns_Manager::format_meta_date` was rebuilt with `DateTimeImmutable(..., wp_timezone())` for the same reason. **`normalise_csv_date` was missed.**
+
+**Suggested fix** — fast-path the common "no explicit timezone" pattern with pure string reformat, fall back to `DateTimeImmutable` with `wp_timezone()` for everything else:
+
+```php
+private static function normalise_csv_date($raw) {
+    $raw = sanitize_text_field((string) $raw);
+    if ($raw === '') {
+        return '';
+    }
+    // Fast path: ISO-ish date with no explicit timezone — treat as WP local
+    // time, no shift.
+    if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+        $second = isset($m[6]) ? $m[6] : '00';
+        return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . $second;
+    }
+    // Fallback: parse in WP timezone (no shift on naive input), convert
+    // to WP timezone (handles explicit-TZ input by shifting to local).
+    try {
+        $dt = (new DateTimeImmutable($raw, wp_timezone()))->setTimezone(wp_timezone());
+        return $dt->format('Y-m-d H:i:s');
+    } catch (Exception $e) {
+        UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+        return '';
+    }
+}
+```
+
+This restores the documented invariant: imported `external_ref_ord_date` reads back at the value the print shop sent.
+
+The drift was hidden in 1.4.2 because `Custom_Columns_Manager::format_meta_date` had its own *opposite* drift bug — the two cancelled out in the columns view. Now that the columns view is correct (1.4.2), the import-side drift is the visible asymmetry.
+
+---
+
+## 3. Medium — pre-existing or longstanding
+
+### 3.1 Plugin assumes HPOS without enforcing it — ✅ resolved in 1.4.4
+
+Resolution: `get_orders_for_printing()` now runs `table_exists("{$wpdb->prefix}wc_orders")` parallel to the existing `wc_order_product_lookup` check. If HPOS is disabled, the export bails with an admin notice ("Export requires WooCommerce HPOS … Enable High-Performance Order Storage …") instead of failing with a cryptic SQL error.
+
+`class-db-manager.php:84` references `{$wpdb->prefix}wc_orders` (the HPOS custom table). The bootstrap declares HPOS compatibility:
+
+```php
+FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+```
+
+The third argument is `$compatible=true`, meaning "this plugin works correctly with HPOS". It does *not* mean "this plugin requires HPOS". If a site has HPOS turned off (legacy CPT storage only), the `wc_orders` table either doesn't exist or is unsynced. The export query then either:
+
+- Errors with "Table 'wp_wc_orders' doesn't exist" — caught by the `$results === false` branch, logged but user-visible as "no exportable items".
+- Returns rows from an incomplete or stale lookup — the export is incorrect but emits no warning.
+
+The plugin has a precedent for surfacing this kind of dependency: `table_exists($wpdb->prefix . 'wc_order_product_lookup')` already runs at the top of the same method (1.4.2 §3.7). A parallel check for `wc_orders` would close the gap, or the plugin could refuse to load entirely under legacy CPT and surface an admin notice à la `woocommerce_missing_notice`.
+
+The Search Manager has fallback (`woocommerce_shop_order_search_fields` for legacy CPT alongside the HPOS filter), but the export path doesn't. Asymmetric.
+
+### 3.2 `bulk_set_print_status` doesn't pass a note to `update_status`, while the import handlers do — ✅ resolved in 1.4.4
+
+Resolution: `bulk_set_print_status` now passes "Marked Prepare to Printing by bulk action." / "Marked In Printing by bulk action." to `update_status`. The audit trail in the order activity feed is now consistent across bulk-action and import paths.
+
+`class-db-manager.php:227`:
+
+```php
+$order->update_status($status_slug);
+```
+
+…versus `class-db-manager.php:280-283`:
+
+```php
+$order->update_status(
+    'in-print',
+    __('Marked In Printing by InPrint Protocol import.', 'studiou-wc-ord-print-statuses')
+);
+```
+
+Bulk-action-triggered transitions get WC's default "Status changed from X to Y" note. Import-triggered transitions get the explanatory `"Marked … by … import."` note. **Asymmetric audit trail.** The 1.4.3 release added explanatory notes to the import paths; the bulk paths were missed.
+
+Pass an explanatory note from `bulk_set_print_status` too — e.g., `"Marked … by bulk action."`.
+
+### 3.3 `normalise_csv_date` fallback (after fix) — `strtotime()` is permissive — ✅ resolved in 1.4.4
+
+Resolution: subsumed by §2.1's fix. `DateTimeImmutable` throws on inputs `strtotime` would silently misinterpret (`'foo'`, `'1'` etc.), so the catch-Exception branch logs and returns empty rather than persisting a meaningless timestamp.
+
+Independent of §2.1, `strtotime` returns a valid timestamp for many ambiguous inputs:
+
+- `strtotime('1')` → current year, January 1st.
+- `strtotime('next thursday')` → next Thursday.
+- `strtotime('foo bar')` → false (the only one that errors).
+
+If a print shop's CSV has a malformed date that happens to parse, the import silently stores a meaningless date. The current `if (!$timestamp)` guard only catches the `false` return value, not the "parsed but absurd" cases.
+
+The fix-suggestion in §2.1 — going through `DateTimeImmutable($raw, wp_timezone())` — is stricter; it throws on unparseable input rather than guessing.
+
+---
+
+## 4. Low — style and cosmetic
+
+### 4.1 Re-entrancy of `$order->save()` inside `handle_status_transitions` — ⏸ left as-is (forward-looking comment kept in code)
+
+The behaviour is correct under WC 10.7.0's caching semantics; the docblock in `class-order-status-manager.php:83-93` already explains the contract. Refactoring would require a more invasive design change to avoid the listener saving entirely.
+
+`class-order-status-manager.php:107-109`. The listener fires from within `woocommerce_order_status_changed`, which is itself inside `WC_Order::update_status()`. The listener calls `$order->save()` to persist the meta change. The parent path (bulk action, import handler, or manual edit) then calls its own `$order->save()` afterwards.
+
+Today this works because WC's data store returns the same object instance from `wc_get_order($id)` while the request is hot — both saves operate on the same object, the second save is a no-op or just re-saves the same state. If WC ever changes the cache semantics (e.g., to return fresh objects after a save), the parent and listener would operate on different instances and the meta change could be clobbered by the parent's save.
+
+Brittle but not broken. Worth a forward-looking comment.
+
+### 4.2 `bulk_set_print_status` and the listener both stamp the same meta key — ⏸ left as-is (intentional, documented)
+
+Acknowledged in `class-order-status-manager.php:71-77`'s docblock as intentional. The redundancy costs one extra `save()` per bulk-changed order, but the behaviour is correct. The 1.4.0 review §2.3 and the 1.4.1 review covered this.
+
+### 4.3 `Custom_Columns_Manager` calls `wc_get_order` per cell — ✅ resolved in 1.4.4
+
+Resolution: under HPOS the column-content callback receives the `WC_Order` object directly — `wc_get_order` is no longer called. Under legacy CPT a per-request `$order_cache` indexed by ID memoises the lookup so the three custom columns share one resolved instance per row.
+
+`class-custom-columns-manager.php:31`. On a 50-orders-per-page list with three custom columns, that's 150 calls per page load. WC's own object cache mitigates the cost, but a per-page-load static cache keyed by ID inside the manager would be tighter.
+
+### 4.4 `emit_import_summary` always shows "%d order(s) updated successfully" even when the count is 0 — ✅ resolved in 1.4.4
+
+Resolution: gated on `$updated > 0`, matching the pattern `Bulk_Actions_Manager::notify_moved_count` introduced in 1.4.3.
+
+`class-import-manager.php:175-186`. If 100% of rows errored, the operator sees "0 orders updated successfully" + the more useful error notice. The `notify_moved_count` helper in `Bulk_Actions_Manager` (1.4.3) already does the suppression-on-zero pattern; the import summary could adopt it.
+
+### 4.5 `add_action('before_woocommerce_init', function () {...})` uses an anonymous callback — ✅ resolved in 1.4.4
+
+Resolution: converted to a named function `studiou_wc_ord_print_statuses_declare_hpos_compat`. Other plugins / test harnesses can now `remove_action('before_woocommerce_init', 'studiou_wc_ord_print_statuses_declare_hpos_compat')` if they need to override.
+
+`studiou-wc-ord-print-statuses.php:26-30`. Anonymous functions cannot be unhooked from outside the file. If another plugin or test setup ever wants to disable the HPOS compatibility declaration, they can't. Cosmetic — converting to a named function is one line.
+
+### 4.6 `Studiou_DB_Manager::csv_escape` would prefix legitimate negative quantities with `'` — ⏸ left as-is
+
+Acknowledged trade-off — OWASP's recommendation to defuse formula injection is stronger than the cosmetic concern of a leading single quote on negative numbers (which Excel hides anyway). If negative quantities ever become a real workflow signal, csv_escape can be adjusted to whitelist numeric leading-minus.
+
+`class-db-manager.php:164-176`. A `qty` of `"-1"` (which shouldn't occur but might in refund-handling scenarios) starts with `-`, so `csv_escape` adds a leading single quote: `"'-1"`. Excel/LibreOffice display strips the quote so the user sees `-1` — no UX impact — but the underlying CSV bytes look unusual to programmatic consumers. The OWASP recommendation is consistent with this trade-off (defending against formula injection is more important than clean negative-number display), but worth noting.
+
+### 4.7 `call_user_func($cfg['handler'], $csv_data)` in `Import_Manager::run_import` — ✅ resolved in 1.4.4
+
+Resolution: replaced with `$handler = $cfg['handler']; $result = $handler($csv_data);` — direct callable invocation. Slightly faster, less verbose.
+
+`class-import-manager.php:129`. PHP 7+ supports invoking array callables directly: `$cfg['handler']($csv_data)`. The `call_user_func` form is slightly slower and slightly more verbose. Style only.
+
+### 4.8 `add_custom_shop_order_column_content`'s `external_order_number` cell relies on `$value !== ''` rather than `$value !== '' && $value !== '—'` — ⏸ left as-is (vanishingly rare edge case)
+
+If anyone ever stores the literal string `"—"` to `external_ref_ord_no` (operator typing it in for an unknown number), the column displays the literal `"—"`. Confusing but a vanishingly rare edge case.
+
+### 4.9 `parse_csv` doesn't handle `\r`-only line endings — ⏸ left as-is (legacy macOS, extremely rare)
+
+`class-db-manager.php:351-384`. PHP's `auto_detect_line_endings` was deprecated in 8.1 and removed in 9.0. macOS-Classic CSVs (`\r`-only) wouldn't tokenise correctly. Extremely unlikely in practice — modern macOS uses `\n`, Excel-Mac uses `\r\n`. Not worth fixing.
+
+### 4.10 `set_orders_to_prepare_printing` and `set_orders_to_in_printing` are thin wrappers — ⏸ left as-is (stable public API surface)
+
+`class-db-manager.php:197-209` — two two-line methods that delegate to `bulk_set_print_status`. The public API would be cleaner as a single `bulk_set_status($order_ids, $slug)` that the bulk action handler calls with the right slug. Refactor-only; behaviour unchanged.
+
+---
+
+## 5. Verification gaps (carried over from earlier reviews)
+
+1. **HPOS search filter name** `woocommerce_order_table_search_query_meta_keys` against WC 10.7.0 source.
+2. **`woocommerce_process_shop_order_meta`** behaviour under the WC 10.7.0 block-based order edit screen.
+3. **`object cache` scenarios for `UtilsLog::message`** (Redis/Memcached).
+
+---
+
+## 6. Status of prior reviews
+
+| Review | Status of in-scope findings | Carried-over deferrals |
+|--------|------------------------------|-------------------------|
+| `revise-1.3.2.md` | All resolved in 1.4.0 | 3 (`Network:` header, uninstall, cosmetic README). |
+| `revise-1.4.0.md` | All resolved in 1.4.1 | 1 (HPOS filter verification). |
+| `revise-1.4.1.md` | All resolved in 1.4.2 | 2 (custom-order-number plugins, register_post_status). |
+| `revise-1.4.2.md` | All resolved in 1.4.3 | 1 (Excel CS-locale CSV separator). |
+
+**One newly introduced regression** identified in this pass: §2.1 above, the side-effect of the 1.4.2 `date()` → `wp_date()` change in `normalise_csv_date`. Worth noting as a process observation: the same class of bug has now appeared in three separate functions across three releases (1.4.0 `Order_Fields_Manager`, 1.4.1 `Custom_Columns_Manager`, 1.4.2 `normalise_csv_date`). A future fix should audit *every* call to `strtotime` + `date_i18n`/`wp_date`/`date` in the codebase for the same pattern.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.4**:
+
+1. **§2.1 `normalise_csv_date` shifts timezone-less CSV dates by the WP offset.** ✅ resolved.
+2. **§3.1 No hard guard against HPOS-disabled installs.** ✅ resolved.
+3. **§3.2 `bulk_set_print_status` doesn't pass an explanatory note.** ✅ resolved.
+4. **§3.3 `normalise_csv_date` fallback accepts permissive `strtotime` interpretations.** ✅ resolved (subsumed by §2.1).
+5. **§4.1 Listener `save()` re-entrancy.** ⏸ left as-is (docblock kept).
+6. **§4.3 Per-cell `wc_get_order` in column rendering.** ✅ resolved.
+7. **§4.4 Zero-count import "success" notice.** ✅ resolved.
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling of the export query on large order volumes.
+- Behavioural verification on the WC 10.x block-based order edit screen.
+- Audit of the refreshed `.po` against the running plugin (the file was rewritten in this cycle; cross-verifying every `msgstr` against the source `msgid` is out of scope for this review).
+- Multisite, uninstall, custom-order-number plugin integration (carried-over deferrals).
+
+---
+
+## 9. Process observation
+
+The two prior fresh-eye reviews (`revise-1.4.1`, `revise-1.4.2`) each surfaced a single new high-severity bug that had survived earlier passes. This review continues the pattern with §2.1 — and the cause is a regression *I introduced* in the 1.4.2 fix for a different timezone bug. The same code shape (`strtotime` + format-in-WP-timezone) appears in three different functions across three releases, fixed each time only in the function specifically called out by the review.
+
+The reliable fix for this class of bug is to **drop `strtotime` for the "stored as WP-local MySQL datetime ↔ display in WP-local" pipeline entirely**. Either pure string reformatting (as `Order_Fields_Manager` now does) or `DateTimeImmutable(..., wp_timezone())` (as `Custom_Columns_Manager` now does) sidesteps the WP UTC-default trap. A grep for `strtotime` across `includes/` would catch any future regression.
+
+Today the only remaining `strtotime` call is in `normalise_csv_date`. After §2.1 is fixed, there should be none.
+
+> **Confirmation in 1.4.4:** a grep for `strtotime` across `includes/` returns zero hits in executable code. Only the docblock comments in `class-custom-columns-manager.php`, `class-db-manager.php`, and `class-order-fields-manager.php` mention it — explaining why each function avoids the pattern.
+
+---
+
+*End of review.*

+ 260 - 0
studiou-wc-ord-print-statuses/docs/revise-1.4.4.md

@@ -0,0 +1,260 @@
+# Plugin Revision — v1.4.4 (Fresh-Eye Analytical Review)
+
+> **Status as of 1.4.5 (2026-05-12):** all four mediums and items §4.1, §4.2 are resolved in plugin version **1.4.5**. §4.3 and §4.4 are documented and deferred (architectural / edge case). §3.4 is documented in README "Known limitations" without a code change. Each finding carries an inline ✅ resolved / ⏸ deferred status marker.
+
+**Scope:** Independent read-only analytical pass over the v1.4.4 codebase. Read with deliberate scepticism toward the changes introduced in 1.4.4 itself, looking for regressions and gaps the previous reviews missed.
+
+**Reviewer date:** 2026-05-12
+**Target environment:** WordPress 6.9.4, WooCommerce 10.7.0 (HPOS enabled), PHP 7.2+.
+**Files reviewed:** all PHP under `includes/`, the bootstrap, the markdown docs, and the `.po` translation source.
+**Verification limitations:** Static review only. No live PHP/WP/WC available.
+
+Severity reflects user-visible or correctness impact.
+
+> **Headline:** no critical findings in this round. The plugin is in a steady state. Four medium-severity items and four low-severity items remain — most are defensive improvements rather than active defects. Two of the mediums (§3.1, §3.3) are *gaps* in fixes I introduced in 1.4.4 itself.
+
+---
+
+## 1. Critical
+
+*(None.)*
+
+---
+
+## 2. High
+
+*(None.)*
+
+---
+
+## 3. Medium — gaps and asymmetries
+
+### 3.1 `normalise_csv_date` fast-path lacks range validation — asymmetric with `datetime_local_to_mysql` — ✅ resolved in 1.4.5
+
+Resolution: the fast path now runs `checkdate($month, $day, $year)` plus explicit hour ≤ 23 / minute ≤ 59 / second ≤ 59 bounds. Inputs that match the regex structurally but fall outside the valid ranges (e.g., `"2026-13-45T25:99"`) drop through to the `DateTimeImmutable` fallback, which throws and routes to the catch → empty string + logged.
+
+`class-db-manager.php:459-462`:
+
+```php
+if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+    $second = isset($m[6]) ? $m[6] : '00';
+    return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . $second;
+}
+```
+
+The regex matches structurally — `"2026-13-45T25:99"` passes — but doesn't validate that the date is real or that the time components are in range. The fast-path returns `"2026-13-45 25:99:00"`, which is then stored to postmeta as-is.
+
+Compare with `Order_Fields_Manager::datetime_local_to_mysql` (`class-order-fields-manager.php:118-131`), which I extended in 1.4.3 to add `checkdate()` plus explicit hour/minute/second bounds. **The same defence belongs on the import side**, because:
+
+- A CSV from the print shop's system could realistically include malformed dates (data-entry typo, locale mismatch, broken upstream tool).
+- The bogus stored value round-trips through display: `Order_Fields_Manager::mysql_to_datetime_local` matches the regex but produces nonsense (`2026-13-45T25:99`), which the browser renders as an empty input. The operator can't tell anything happened until they save the order — at which point `datetime_local_to_mysql` finally rejects it.
+
+**Suggested fix** — apply the same validation in the fast path:
+
+```php
+if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+    list(, $y, $mo, $d, $h, $mi) = $m;
+    $s = isset($m[6]) ? $m[6] : '00';
+    if (!checkdate((int) $mo, (int) $d, (int) $y) || (int) $h > 23 || (int) $mi > 59 || (int) $s > 59) {
+        // Fall through to the DateTimeImmutable path, which will throw on
+        // unparseable values and reach our catch.
+        return self::normalise_csv_date_fallback($raw);
+    }
+    return $y . '-' . $mo . '-' . $d . ' ' . $h . ':' . $mi . ':' . $s;
+}
+```
+
+…or simpler: drop the fast path entirely and always use `DateTimeImmutable($raw, wp_timezone())`. The fast path was an optimisation; the fallback handles the common case correctly too.
+
+### 3.2 `emit_import_summary` is silent when 0 successes and 0 errors — ✅ resolved in 1.4.5
+
+Resolution: added an `elseif (empty($errors))` branch that emits an info notice ("The CSV file contained no actionable rows (empty order_no column).") so the operator gets feedback even when dedup-by-order_no filters out every row.
+
+`class-import-manager.php:172-209`. The 1.4.4 change gated the "%d orders updated successfully" notice on `$updated > 0`. That's correct *when* there are errors to surface, but if both counters are zero the operator sees nothing.
+
+How can both be zero? `run_import` already bails earlier when `$csv_data` is empty (line 121). But `dedupe_by_order_no` (line 413) further filters out rows whose `order_no` is empty after `trim()`. A CSV with header + N rows where every row has an empty `order_no` column produces `count($csv_data) === N` but yields zero rows after dedup. No order_no → no `wc_get_order` lookup → no error entry → zero on both sides.
+
+The operator gets no feedback that the import "succeeded" (it ran without error) but did nothing.
+
+**Suggested fix** — add an else branch:
+
+```php
+} elseif (empty($errors)) {
+    UtilsLog::message(
+        __('The CSV file contained no actionable rows (empty order_no column).', 'studiou-wc-ord-print-statuses'),
+        'info'
+    );
+}
+```
+
+### 3.3 `csv_escape` checks only the literal first character — leading-whitespace bypass — ✅ resolved in 1.4.5
+
+Resolution: `csv_escape` now calls `ltrim` and checks the first non-whitespace character. Cells like `" =CMD"` are now prefixed with `'`.
+
+`class-db-manager.php:174-186`:
+
+```php
+$first = $value[0];
+if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
+    return "'" . $value;
+}
+```
+
+Cells beginning with a space followed by a danger character (e.g., `" =CMD"`) slip past — `$value[0]` is the space. Some spreadsheet apps trim leading whitespace before evaluating, others don't; behaviour varies by version and config.
+
+**Vector likelihood:** low in this codebase — the danger-prone fields are `prod_name`, `prod_var`, `prod_cat`, `email`. Product / category names are admin-controlled. `email` is customer-controlled but WC's email validation rejects strings starting with space-equals.
+
+Still, the OWASP recommendation is to check the value after `ltrim`, not just `$value[0]`. One-line fix:
+
+```php
+$trimmed = ltrim($value);
+if ($trimmed !== '' && in_array($trimmed[0], array('=', '+', '-', '@', "\t", "\r"), true)) {
+    return "'" . $value;
+}
+```
+
+### 3.4 Possible collation mismatch on `terms.slug = postmeta.meta_value` — ⏸ documented in README "Known limitations"
+
+Resolution path: the issue is theoretical and hasn't been observed in production. Adding an explicit `COLLATE` clause to the join condition risks breaking installs whose actual collation differs from whatever we'd hardcode. The pragmatic choice is to document the failure mode (which manifests as `ERROR 1267 (Illegal mix of collations)` from the bulk export) so future operators can add the fix if it surfaces. README "Known limitations" now mentions this.
+
+`class-db-manager.php:104-105`:
+
+```sql
+LEFT JOIN `{$wpdb->terms}` `product_variations_name`
+    ON `product_variations_name`.`slug` = `product_variations_attr`.`meta_value`
+```
+
+`terms.slug` is `VARCHAR(200)`; `postmeta.meta_value` is `LONGTEXT`. Both columns *should* use `utf8mb4_unicode_ci` on a default WP install, but installs that have been around long enough to have been migrated across MySQL versions (or had per-table collation changes applied) can end up with mismatched collations.
+
+When MySQL encounters a join condition between columns with incompatible collations, it can either:
+- Throw `ERROR 1267 (HY000): Illegal mix of collations` and abort the query, or
+- Implicitly coerce via temporary string conversion, which can silently change case sensitivity / accent handling for that join.
+
+The export query has no `COLLATE` clause, so it inherits the DB-default collation. Most production sites won't be affected — Studiou's install presumably wasn't, given that earlier 1.3.2 / 1.4.0 / 1.4.1 reviews didn't surface this. Worth noting as an unbounded edge case rather than a confirmed bug.
+
+**Mitigation** (only if it becomes a problem): add `COLLATE utf8mb4_unicode_ci` to the JOIN condition.
+
+---
+
+## 4. Low
+
+### 4.1 `bulk_set_print_status` note selection is a binary ternary — implicit else — ✅ resolved in 1.4.5
+
+Resolution: lifted to an explicit `array('to-print' => …, 'in-print' => …)` map with `isset` fallback to empty string. Future contributors adding a third slug get a no-op note rather than the wrong note.
+
+`class-db-manager.php:229-231`:
+
+```php
+$note = ($status_slug === 'to-print')
+    ? __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses')
+    : __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses');
+```
+
+The function is only ever called with `'to-print'` or `'in-print'`, so the else branch is correct today. If a future contributor extends the helper to a third status, the else-implicit selection of "In Printing" would silently produce a misleading note. Defensive option: a small dispatch map.
+
+### 4.2 `parse_csv` doesn't detect duplicate headers — ✅ resolved in 1.4.5
+
+Resolution: after `array_map(normalise_header, $headers)`, `parse_csv` compares `count($headers)` against `count(array_unique($headers))` and bails (with an `UtilsLog::log` entry naming the headers) on mismatch. The downstream import then emits the existing "could not be parsed" notice.
+
+`class-db-manager.php:391-397`:
+
+```php
+$headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
+while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
+    if (count($data) !== count($headers)) {
+        continue;
+    }
+    $csv_data[] = array_combine($headers, $data);
+}
+```
+
+If the CSV has two columns sharing a normalised name (e.g., `Order No` and `order_no` both normalising to `order_no`), `array_combine` silently overwrites — the second column's data wins for every row, the first column's data is lost. The user gets no warning.
+
+Realistic? Unlikely with a well-formed protocol CSV. Defensive — add `count($headers) === count(array_unique($headers))` check and abort with a clear notice if not.
+
+### 4.3 Main plugin instance is short-lived; managers persist via the WP filter array — ⏸ left as-is (architectural)
+
+Resolution path: refactoring would require either making the main instance a singleton (with `getInstance()`) or moving manager initialisation to a static method. Both are larger refactors than the symptom warrants — there is no functional impact, only a documentation concern. Worth a CLAUDE.md note if the lifecycle pattern surprises a future contributor.
+
+`studiou-wc-ord-print-statuses.php:47-50`:
+
+```php
+public static function initPlugin() {
+    $class = __CLASS__;
+    new $class;
+}
+```
+
+The new instance has no global / static reference. After `__construct()` calls `init()` and `init()` returns, the local `$class` variable goes out of scope and PHP's reference counter for the main instance hits zero. The instance is GC'd.
+
+The managers stored in `$this->order_status_manager` etc. would normally be GC'd along with the main instance — *except* each manager's constructor registers WP hooks with `array($this, '...')`, which adds references to the manager instances inside the global `$wp_filter` array. Those references keep the managers alive for the rest of the request.
+
+So the private `$order_status_manager` / `$order_fields_manager` / etc. fields on `Studiou_WC_Ord_Print_Statuses` are effectively *decorative* — they only matter during the few microseconds of `initialize_managers()`. After `init()` returns they're inaccessible.
+
+**Implications:** none in normal flow. But code that tries to look up "the plugin's order status manager" via `Studiou_WC_Ord_Print_Statuses` (e.g., a third-party hook integrating with this plugin) will find no instance. The pattern is unusual and worth documenting.
+
+### 4.4 `woocommerce_missing_notice` registered conditionally but never unregistered — ⏸ left as-is (edge case)
+
+`studiou-wc-ord-print-statuses.php:54-60`. If WC is not yet active when our `init()` runs, the notice hook is registered. If WC becomes active later in the same request (extremely unlikely — WC would have to be activated mid-request by another plugin's hook), the notice would still display once on the next admin page load. Edge case.
+
+---
+
+## 5. Verification gaps (carried over from earlier reviews)
+
+1. **HPOS search filter name** `woocommerce_order_table_search_query_meta_keys` against WC 10.7.0 source. The legacy filter `woocommerce_shop_order_search_fields` may not fire under HPOS at all — making it not a fallback. Smoke test by adding a temporary `error_log` inside the callback and observing whether the HPOS path actually fires.
+2. **`woocommerce_process_shop_order_meta`** behaviour under the WC 10.7.0 block-based order edit screen.
+3. **Object-cache scenarios for `UtilsLog::message`** (Redis/Memcached).
+
+---
+
+## 6. Status of prior reviews
+
+| Review | Resolved | Carried-over deferrals |
+|--------|----------|-------------------------|
+| `revise-1.3.2.md` | 1.4.0 | 3 (`Network:` header, uninstall, README phrasing). |
+| `revise-1.4.0.md` | 1.4.1 | 1 (HPOS filter verification). |
+| `revise-1.4.1.md` | 1.4.2 | 2 (custom-order-number plugins, register_post_status). |
+| `revise-1.4.2.md` | 1.4.3 | 1 (Excel CS-locale CSV separator). |
+| `revise-1.4.3.md` | 1.4.4 | (listener save() re-entrancy, bulk/listener double-stamp — both intentional, documented; csv_escape on negatives — OWASP trade-off; "—" literal column, `\r`-only line endings, `set_orders_to_*` API surface — vanishingly rare). |
+
+**No new regressions introduced by 1.4.4** in the strict sense. §3.1 and §3.3 are *under-applied defences* — patterns that should have extended to the import path / been stricter in csv_escape — rather than active bugs.
+
+---
+
+## 7. Risk-prioritised summary
+
+Ordered by user-visible severity. Status as of **1.4.5**:
+
+1. **§3.1 `normalise_csv_date` fast-path missing range validation.** ✅ resolved.
+2. **§3.2 Silent import when all rows have empty `order_no`.** ✅ resolved.
+3. **§3.3 `csv_escape` leading-whitespace bypass.** ✅ resolved.
+4. **§3.4 Collation mismatch on slug join.** ⏸ documented in README "Known limitations".
+5. **§4.1 Binary note selection in `bulk_set_print_status`.** ✅ resolved.
+6. **§4.2 Duplicate-header detection in `parse_csv`.** ✅ resolved.
+7. **§4.3 Main plugin instance lifecycle.** ⏸ left as-is (architectural, no functional impact).
+8. **§4.4 `woocommerce_missing_notice` re-fire.** ⏸ left as-is (edge case).
+
+---
+
+## 8. Out of scope
+
+- Live behaviour testing against a running WP 6.9.4 / WC 10.7.0 install.
+- Performance profiling under realistic order volumes.
+- Behavioural verification on the WC 10.x block-based order edit screen.
+- Multisite, uninstall, custom-order-number plugin integration (carried-over deferrals).
+- Verifying every `msgstr` in the refreshed `.po` against the running plugin.
+
+---
+
+## 9. Process observation
+
+The "single new HIGH per fresh-eye pass" pattern from 1.4.0 → 1.4.3 has tailed off. This pass surfaces only mediums and lows. Either:
+
+- The plugin has genuinely reached a stable state (likely), or
+- I'm developing scope blindness on this codebase after seven reviews and four fix-up releases (possible — fresh-eye reviews work best with rotating reviewers).
+
+If a second reviewer is available, this is a good moment for an outside pass. Otherwise, the diminishing-returns curve suggests the next iteration of fixes should focus on the carried-over verification gaps (live testing against WC 10.7.0) rather than another static review cycle.
+
+---
+
+*End of review.*

+ 111 - 0
studiou-wc-ord-print-statuses/docs/translations.txt

@@ -0,0 +1,111 @@
+Translations: all user-facing strings (English → Czech).
+Last reviewed: v1.5.0 (2026-05-12).
+
+Note: After editing the .po file, regenerate the .mo binary with one of:
+  wp i18n make-mo languages/
+  msgfmt languages/studiou-wc-ord-print-statuses-cs_CZ.po -o languages/studiou-wc-ord-print-statuses-cs_CZ.mo
+
+English                                                                                     Česky
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------
+# Bulk actions
+Prepare to Printing Export                                                                  Export do přípravy tisku
+Set Status to Prepare to Printing                                                           Nastav stav na Příprava Tisku
+Set Status to In Printing                                                                   Nastav stav na V Tisku
+
+# Order list columns
+To Print Date                                                                               Datum Příprava Tisku
+In Print Date                                                                               Datum V Tisku
+External Order Number                                                                       Č.Obj. Tiskárny
+
+# Print Information panel (order edit screen)
+Print Information                                                                           Informace Tiskové služby
+Prepare To Print Date                                                                       Datum stavu Příprava Tisku
+External Order Number                                                                       Č.Obj. Tiskárny
+External Order Date                                                                         Datum Obj. Tiskárny
+External Order Delivered                                                                    Datum Dodání Tisku
+
+# Custom order statuses
+Prepare to Printing                                                                         Příprava Tisku
+In Printing                                                                                 V Tisku
+
+# Plurals for status counts (use %s placeholder)
+Prepare to Printing <span class="count">(%s)</span>                                         Příprava Tisku <span class="count">(%s)</span>
+In Printing <span class="count">(%s)</span>                                                 V Tisku <span class="count">(%s)</span>
+
+# Import Protocols admin screen
+Import Protocols                                                                            Import Protokolů
+Import InPrint Protocol                                                                     Import protokolu V Tisku
+Import Delivered Protocol                                                                   Import protokolu Tisk Dodán
+Required CSV headers: %s                                                                    Povinné hlavičky CSV: %s
+
+# Plurals / formatted notices (use %d / %1$d / %2$s as in source)
+%d order updated successfully.                                                              %d objednávka úspěšně aktualizována.
+%d orders updated successfully.                                                             %d objednávek úspěšně aktualizováno.
+%d order moved to "Prepare to Printing".                                                    %d objednávka přesunuta na "Příprava Tisku".
+%d orders moved to "Prepare to Printing".                                                   %d objednávek přesunuto na "Příprava Tisku".
+%d order moved to "In Printing".                                                            %d objednávka přesunuta na "V Tisku".
+%d orders moved to "In Printing".                                                           %d objednávek přesunuto na "V Tisku".
+%d order skipped because its status is final (cancelled, refunded, failed, or completed).   %d objednávka přeskočena, protože je v koncovém stavu (zrušeno, vráceno, selhalo, dokončeno).
+%d orders skipped because their status is final (cancelled, refunded, failed, or completed). %d objednávek přeskočeno, protože jsou v koncovém stavu (zrušeno, vráceno, selhalo, dokončeno).
+%1$d errors occurred: %2$s                                                                  Počet chyb: %1$d — %2$s
+No exportable line items were found for the selected orders. CSV was not generated.         Pro vybrané objednávky nebyly nalezeny žádné položky k exportu. CSV nebylo vygenerováno.
+
+# Error / status messages
+Order %s not found.                                                                         Objednávka č. %s nenalezena.
+Order %s is in a final state and was not updated.                                           Objednávka č. %s je v koncovém stavu a nebyla aktualizována.
+Invalid row format in CSV.                                                                  Vadný CSV formát.
+No file uploaded.                                                                           Žádný soubor nenahrán.
+Uploaded file is not accessible.                                                            Nahraný soubor není dostupný.
+File upload failed (error code %d).                                                         Nahrání souboru selhalo (chybový kód %d).
+CSV file could not be parsed or contained no data rows.                                     CSV soubor se nepodařilo zpracovat nebo neobsahoval žádné datové řádky.
+You do not have sufficient permissions to access this page.                                 Nemáte dostatečná oprávnění k zobrazení této stránky.
+You do not have sufficient permissions to perform this action.                              Nemáte dostatečná oprávnění k provedení této akce.
+
+# Activation guard
+Studiou WC Order Print Statuses requires WooCommerce to be installed and active.            StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce.
+
+# CSV export safety guard (1.4.1)
+The CSV export could not be streamed because output had already started. Check for plugin/theme code that prints before headers are sent.  CSV export nebylo možné odeslat, protože výstup již začal. Zkontrolujte kód pluginů/šablon, který tiskne před odesláním HTTP hlaviček.
+
+# New in 1.4.2
+Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.  Export vyžaduje tabulku WooCommerce analytics (wc_order_product_lookup). Aktivujte WooCommerce Analytics nebo kontaktujte podporu.
+Only .csv files are accepted.                                                                Přijímány jsou pouze soubory .csv.
+
+# New in 1.4.3 — order notes passed to update_status from import paths
+Marked In Printing by InPrint Protocol import.                                              Označeno jako "V Tisku" importem protokolu V Tisku.
+Marked completed by Delivered Protocol import.                                              Označeno jako "Dokončeno" importem protokolu Tisk Dodán.
+
+# New in 1.4.4 — order notes passed to update_status from bulk-action paths
+Marked Prepare to Printing by bulk action.                                                  Označeno jako "Příprava Tisku" hromadnou akcí.
+Marked In Printing by bulk action.                                                          Označeno jako "V Tisku" hromadnou akcí.
+
+# New in 1.4.4 — HPOS table-missing admin notice
+Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support.  Export vyžaduje WooCommerce HPOS (vlastní tabulky objednávek). Aktivujte High-Performance Order Storage v WooCommerce → Nastavení → Pokročilé → Funkce, nebo kontaktujte podporu.
+
+# New in 1.4.5 — import-summary info notice when dedup filters out every row
+The CSV file contained no actionable rows (empty order_no column).                          CSV soubor neobsahoval žádné použitelné řádky (sloupec order_no je prázdný).
+
+# New in 1.5.0 — per-order-item print status feature
+# Status labels
+Pending Print                                                                               Čeká na Tisk
+In Print                                                                                    V Tisku
+Done Print                                                                                  Vytištěno
+Skip Print                                                                                  Přeskočit Tisk
+
+# Order item column header
+Print Status                                                                                Stav Tisku
+
+# Order activity notes
+Item #%1$d (%2$s): %3$s → %4$s.                                                             Položka č. %1$d (%2$s): %3$s → %4$s.
+%d line item advanced to "In Print" by order-status change.                                 %d položka přesunuta na "V Tisku" kvůli změně stavu objednávky.
+%d line items advanced to "In Print" by order-status change.                                %d položek přesunuto na "V Tisku" kvůli změně stavu objednávky.
+%d line item advanced to "Done Print" by Delivered Protocol import.                         %d položka přesunuta na "Vytištěno" importem protokolu Tisk Dodán.
+%d line items advanced to "Done Print" by Delivered Protocol import.                        %d položek přesunuto na "Vytištěno" importem protokolu Tisk Dodán.
+
+# Completion guard
+Cannot complete: product line items still pending print. Resolve item statuses first.       Nelze dokončit: položky objednávky ještě nejsou vytištěné. Nejprve vyřešte stavy položek.
+Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".   Dokončení objednávky č. %1$d zablokováno — %2$d položek dosud není ve stavu "Vytištěno" nebo "Přeskočit Tisk".
+
+# Delivered Protocol summary notice (across all orders in one import)
+%d line item advanced to "Done Print" to satisfy completion guard.                          %d položka přesunuta na "Vytištěno" pro splnění podmínky dokončení.
+%d line items advanced to "Done Print" to satisfy completion guard.                         %d položek přesunuto na "Vytištěno" pro splnění podmínky dokončení.

+ 98 - 41
studiou-wc-ord-print-statuses/includes/class-bulk-actions-manager.php

@@ -1,6 +1,8 @@
 <?php
 // Handles custom bulk actions for orders.
-// visualized
+
+defined('ABSPATH') || exit;
+
 class Bulk_Actions_Manager {
     public function __construct() {
         add_filter('bulk_actions-woocommerce_page_wc-orders', array($this, 'register_bulk_actions'));
@@ -8,83 +10,138 @@ class Bulk_Actions_Manager {
     }
 
     public function register_bulk_actions($bulk_actions) {
-        $bulk_actions['prepare_to_printing_export'] = __('Prepare to Printing Export', 'studiou-wc-ord-print-statuses');
+        $bulk_actions['prepare_to_printing_export']       = __('Prepare to Printing Export', 'studiou-wc-ord-print-statuses');
         $bulk_actions['set_status_to_prepare_to_printing'] = __('Set Status to Prepare to Printing', 'studiou-wc-ord-print-statuses');
-        $bulk_actions['set_status_to_in_printing'] = __('Set Status to In Printing', 'studiou-wc-ord-print-statuses');
-
-        UtilsLog::log('Bulk actions registered');
+        $bulk_actions['set_status_to_in_printing']         = __('Set Status to In Printing', 'studiou-wc-ord-print-statuses');
         return $bulk_actions;
     }
 
     public function handle_bulk_actions($redirect_to, $action, $post_ids) {
+        $known = array(
+            'prepare_to_printing_export',
+            'set_status_to_prepare_to_printing',
+            'set_status_to_in_printing',
+        );
+        if (!in_array($action, $known, true)) {
+            return $redirect_to;
+        }
+        if (!current_user_can('edit_shop_orders')) {
+            wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'studiou-wc-ord-print-statuses'));
+        }
+        $post_ids = array_filter(array_map('intval', (array) $post_ids));
+        if (empty($post_ids)) {
+            return $redirect_to;
+        }
+
         switch ($action) {
             case 'prepare_to_printing_export':
-                $redirect_to = $this->prepare_to_printing_export($post_ids, $redirect_to);
-                break;
+                return $this->prepare_to_printing_export($post_ids, $redirect_to);
             case 'set_status_to_prepare_to_printing':
-                $redirect_to = $this->set_status_to_prepare_to_printing($post_ids, $redirect_to);
-                break;
+                return $this->set_status_to_prepare_to_printing($post_ids, $redirect_to);
             case 'set_status_to_in_printing':
-                $redirect_to = $this->set_status_to_in_printing($post_ids, $redirect_to);
-                break;
+                return $this->set_status_to_in_printing($post_ids, $redirect_to);
         }
         return $redirect_to;
     }
 
+    /**
+     * Atomic order: pull rows, build the CSV in memory, then flip statuses, then
+     * stream the file. If the export query returns nothing, statuses are still
+     * advanced (the spec requires it) but the user is told no CSV was produced.
+     */
     private function prepare_to_printing_export($post_ids, $redirect_to) {
-        // Get orders data using the DB Manager
         $orders_data = Studiou_DB_Manager::get_orders_for_printing($post_ids);
+        $csv_data    = !empty($orders_data) ? $this->generate_csv($orders_data) : '';
 
-        // Update order statuses
-        $this->set_status_to_prepare_to_printing($post_ids, $redirect_to);
-
-        UtilsLog::log('Order statuses set to "to-print" after export.');
-        UtilsLog::message('Order statuses (' . count($orders_data) . ') set to "to-print" after export.', 'info' );
+        $updated = Studiou_DB_Manager::set_orders_to_prepare_printing($post_ids);
+        $this->notify_moved_count($updated, 'to-print');
 
-        if (!empty($orders_data)) {
-            $csv_data = $this->generate_csv($orders_data);
-            UtilsLog::log('file prepare_to_printing_export.csv exported');
-            $this->output_csv($csv_data, 'prepare_to_printing_export.csv');
+        if ($csv_data === '') {
+            UtilsLog::message(
+                __('No exportable line items were found for the selected orders. CSV was not generated.', 'studiou-wc-ord-print-statuses'),
+                'warning'
+            );
+            return $redirect_to;
         }
 
-        return $redirect_to;
+        $this->output_csv($csv_data, 'prepare_to_printing_export.csv'); // exits
     }
 
     private function set_status_to_prepare_to_printing($post_ids, $redirect_to) {
-        // Use the DB Manager to update order statuses
-        Studiou_DB_Manager::set_orders_to_prepare_printing($post_ids);
-        
-        $redirect_to = add_query_arg('bulk_action', 'marked_prepare_to_printing', $redirect_to);
-        return $redirect_to;
+        $updated = Studiou_DB_Manager::set_orders_to_prepare_printing($post_ids);
+        $this->notify_moved_count($updated, 'to-print');
+        return add_query_arg('bulk_action', 'marked_prepare_to_printing', $redirect_to);
     }
 
     private function set_status_to_in_printing($post_ids, $redirect_to) {
-        // Use the DB Manager to update order statuses
-        Studiou_DB_Manager::set_orders_to_in_printing($post_ids);
-        
-        $redirect_to = add_query_arg('bulk_action', 'marked_in_printing', $redirect_to);
-        return $redirect_to;
+        $updated = Studiou_DB_Manager::set_orders_to_in_printing($post_ids);
+        $this->notify_moved_count($updated, 'in-print');
+        return add_query_arg('bulk_action', 'marked_in_printing', $redirect_to);
     }
 
-    private function generate_csv($data) {
-        $output = fopen('php://temp', 'w');
-        fputcsv($output, array_keys((array)$data[0]),','); // Headers
+    /**
+     * Emit a "%d orders moved to …" notice unless zero orders were actually
+     * changed — in that case the "skipped because terminal status" warning
+     * from Studiou_DB_Manager is more informative on its own.
+     */
+    private function notify_moved_count($count, $slug) {
+        if ($count <= 0) {
+            return;
+        }
+        if ($slug === 'to-print') {
+            $message = _n(
+                '%d order moved to "Prepare to Printing".',
+                '%d orders moved to "Prepare to Printing".',
+                $count,
+                'studiou-wc-ord-print-statuses'
+            );
+        } else {
+            $message = _n(
+                '%d order moved to "In Printing".',
+                '%d orders moved to "In Printing".',
+                $count,
+                'studiou-wc-ord-print-statuses'
+            );
+        }
+        UtilsLog::message(sprintf($message, $count), 'success');
+    }
 
+    private function generate_csv($data) {
+        $output = fopen('php://temp', 'w+');
+        // Explicit empty-string escape suppresses the PHP 8.4 deprecation
+        // notice for the implicit '\\' default.
+        fputcsv($output, array_keys((array) $data[0]), ',', '"', '');
         foreach ($data as $row) {
-            fputcsv($output, (array)$row, ',');
+            fputcsv($output, (array) $row, ',', '"', '');
         }
-
         rewind($output);
         $csv = stream_get_contents($output);
         fclose($output);
-
         return $csv;
     }
 
+    /**
+     * Stream CSV with UTF-8 BOM so Excel on Windows reads diacritics correctly.
+     * If output has already started (some upstream code printed something) we
+     * cannot reliably stream a CSV download — bail with a clear error rather
+     * than echoing CSV bytes into an HTML page.
+     */
     private function output_csv($csv_data, $filename) {
-        header('Content-Type: text/csv');
+        if (headers_sent($file, $line)) {
+            UtilsLog::log(sprintf(
+                'output_csv: headers already sent from %s:%d — aborting CSV stream.',
+                $file,
+                $line
+            ));
+            wp_die(esc_html__(
+                'The CSV export could not be streamed because output had already started. Check for plugin/theme code that prints before headers are sent.',
+                'studiou-wc-ord-print-statuses'
+            ));
+        }
+        nocache_headers();
+        header('Content-Type: text/csv; charset=utf-8');
         header('Content-Disposition: attachment; filename="' . $filename . '"');
-        echo $csv_data;
+        echo "\xEF\xBB\xBF" . $csv_data; // UTF-8 BOM
         exit;
     }
-}
+}

+ 102 - 51
studiou-wc-ord-print-statuses/includes/class-custom-columns-manager.php

@@ -1,51 +1,102 @@
-<?php
-//Manages custom columns in the order list.
-// visualized
-
-class Custom_Columns_Manager {
-    public function __construct() {
-        add_filter('manage_woocommerce_page_wc-orders_columns', array($this, 'add_custom_shop_order_column'));
-        add_action('manage_woocommerce_page_wc-orders_custom_column', array($this, 'add_custom_shop_order_column_content'), 10, 2);
-        //add_filter('manage_shop_order_posts_custom_column', array($this, 'add_custom_shop_order_column'));
-        //add_action('manage_woocommerce_page_wc-orders_custom_column', array($this, 'add_custom_shop_order_column_content'), 10, 2);
-        //manage_shop_order_posts_custom_column
-    }
-
-    public function add_custom_shop_order_column($columns) {
-        $new_columns = array();
-
-        UtilsLog::log('Begin registering order columns...');
-        foreach ($columns as $column_name => $column_info) {
-            $new_columns[$column_name] = $column_info;
-
-            if ($column_name === 'order_total') {
-                $new_columns['to_print_date'] = __('To Print Date', 'studiou-wc-ord-print-statuses');
-                $new_columns['in_print_date'] = __('In Print Date', 'studiou-wc-ord-print-statuses');
-                $new_columns['external_order_number'] = __('External Order Number', 'studiou-wc-ord-print-statuses');
-                UtilsLog::log('3 order columns added');
-            }
-        }
-
-        return $new_columns;
-    }
-
-    public function add_custom_shop_order_column_content($column, $post_id) {
-        $order = wc_get_order($post_id);
-
-        switch ($column) {
-            case 'to_print_date':
-                $to_print_date = $order->get_meta('to_print_date');
-                echo $to_print_date ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($to_print_date)) : '—';
-                break;
-
-            case 'in_print_date':
-                $in_print_date = $order->get_meta('in_print_date');
-                echo $in_print_date ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($in_print_date)) : '—';
-                break;
-
-            case 'external_order_number':
-                echo $order->get_meta('external_ref_ord_no') ?: '—';
-                break;
-        }
-    }
-}
+<?php
+// Manages custom columns in the WooCommerce orders list (HPOS and legacy).
+
+defined('ABSPATH') || exit;
+
+class Custom_Columns_Manager {
+    /**
+     * Per-request cache of resolved WC_Order objects keyed by order ID.
+     * Used only on the legacy CPT path where WP passes us the order ID;
+     * under HPOS WC already hands us the WC_Order instance directly.
+     * @var array<int,WC_Order|false>
+     */
+    private $order_cache = array();
+
+    public function __construct() {
+        add_filter('manage_woocommerce_page_wc-orders_columns', array($this, 'add_custom_shop_order_column'));
+        add_action('manage_woocommerce_page_wc-orders_custom_column', array($this, 'add_custom_shop_order_column_content'), 10, 2);
+    }
+
+    public function add_custom_shop_order_column($columns) {
+        $new_columns = array();
+        foreach ($columns as $column_name => $column_info) {
+            $new_columns[$column_name] = $column_info;
+            if ($column_name === 'order_total') {
+                $new_columns['to_print_date']         = __('To Print Date', 'studiou-wc-ord-print-statuses');
+                $new_columns['in_print_date']         = __('In Print Date', 'studiou-wc-ord-print-statuses');
+                $new_columns['external_order_number'] = __('External Order Number', 'studiou-wc-ord-print-statuses');
+            }
+        }
+        return $new_columns;
+    }
+
+    /**
+     * Render cell content. Under HPOS WC passes the WC_Order object as the
+     * second argument; under legacy CPT it passes the post ID. Use the
+     * object directly when WC hands us one (skipping the wc_get_order
+     * lookup entirely), and memoise by ID for the legacy path so we don't
+     * re-resolve the same order for each of the three custom columns.
+     */
+    public function add_custom_shop_order_column_content($column, $order_or_id) {
+        if ($order_or_id instanceof WC_Abstract_Order) {
+            $order = $order_or_id;
+        } else {
+            $id = (int) $order_or_id;
+            if (!array_key_exists($id, $this->order_cache)) {
+                $this->order_cache[$id] = wc_get_order($id);
+            }
+            $order = $this->order_cache[$id];
+        }
+        if (!$order) {
+            echo '—';
+            return;
+        }
+        switch ($column) {
+            case 'to_print_date':
+                echo $this->format_meta_date($order->get_meta('to_print_date'));
+                break;
+            case 'in_print_date':
+                echo $this->format_meta_date($order->get_meta('in_print_date'));
+                break;
+            case 'external_order_number':
+                $value = $order->get_meta('external_ref_ord_no');
+                echo $value !== '' ? esc_html($value) : '—';
+                break;
+        }
+    }
+
+    /**
+     * Format a MySQL datetime meta value for display.
+     *
+     * The stored value (written by current_time('mysql')) is already in WP
+     * local time. Previously this method used strtotime()/date_i18n() which
+     * applied the timezone offset twice on hosts where the PHP server
+     * timezone differs from the WP site timezone (e.g. UTC server +
+     * Europe/Prague site → display drifted by 1–2 hours).
+     *
+     * We now parse the string as WP-local time explicitly, then format via
+     * wp_date() which respects WP timezone. This keeps locale-aware
+     * formatting while eliminating the offset drift.
+     */
+    private function format_meta_date($value) {
+        if (empty($value)) {
+            return '—';
+        }
+        $value = trim((string) $value);
+        // Accept both 'YYYY-MM-DD HH:MM:SS' and 'YYYY-MM-DDTHH:MM[:SS]'.
+        $normalised = str_replace('T', ' ', $value);
+        if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/', $normalised)) {
+            return '—';
+        }
+        if (strlen($normalised) === 16) { // missing seconds
+            $normalised .= ':00';
+        }
+        try {
+            $dt = new DateTimeImmutable($normalised, wp_timezone());
+        } catch (Exception $e) {
+            return '—';
+        }
+        $fmt = get_option('date_format') . ' ' . get_option('time_format');
+        return esc_html(wp_date($fmt, $dt->getTimestamp()));
+    }
+}

+ 467 - 163
studiou-wc-ord-print-statuses/includes/class-db-manager.php

@@ -1,249 +1,553 @@
 <?php
 /**
- * Database operations manager for the Studiou WC Order Print Statuses plugin.
- * 
- * This class centralizes database queries for better maintainability.
+ * Database operations and CSV-protocol processing for the
+ * Studiou WC Order Print Statuses plugin.
  */
 
 defined('ABSPATH') || exit;
 
 class Studiou_DB_Manager {
     /**
-     * Get orders data for printing export
+     * Order statuses that block automated print-pipeline transitions.
+     * Cancelled / refunded orders should not be silently reactivated by a bulk
+     * action; completed orders are already finished.
+     */
+    const TERMINAL_STATUSES = array('cancelled', 'refunded', 'failed', 'completed');
+
+    /**
+     * Return one row per (order × line-item) for the print-shop export.
+     *
+     * Design notes:
+     * - Multiple product categories are concatenated into prod_cat instead of
+     *   multiplying the row.
+     * - Simple (non-variable) products are included via LEFT JOIN; their
+     *   prod_var / prod_var_type fields will be NULL.
+     * - The image URL is resolved in PHP via wp_get_attachment_url() rather
+     *   than reading wp_posts.guid, which the codex warns is not guaranteed
+     *   to be a URL (breaks under offloaded media / multisite uploads paths).
+     * - The variation attribute is joined on terms.slug — WC stores the slug
+     *   in `attribute_pa_format` postmeta, not the term name.
+     * - qty is summed across duplicate line items (a customer adding the
+     *   same variation twice without merging produces two rows in the
+     *   lookup table; MIN() would silently undercount).
      *
-     * @param array $order_ids Array of order IDs
-     * @return array Array of order data
+     * @param int[] $order_ids
+     * @return array Each element is an stdClass with fields:
+     *               order_no, prod_cat, prod_name, prod_var, prod_var_type,
+     *               prod_img_url, qty, email.
      */
     public static function get_orders_for_printing($order_ids) {
         global $wpdb;
-        
-        $query = "
+
+        // Explicit callback so the filter doesn't accidentally drop legitimate
+        // zero values if this helper is ever repurposed for non-ID lists.
+        $order_ids = array_filter(
+            array_map('intval', (array) $order_ids),
+            function ($id) { return $id > 0; }
+        );
+        if (empty($order_ids)) {
+            return array();
+        }
+
+        $orders_table = $wpdb->prefix . 'wc_orders';
+        if (!self::table_exists($orders_table)) {
+            UtilsLog::log("get_orders_for_printing: required HPOS table {$orders_table} is missing. Is WooCommerce HPOS enabled?");
+            UtilsLog::message(
+                __('Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support.', 'studiou-wc-ord-print-statuses'),
+                'error'
+            );
+            return array();
+        }
+
+        $lookup_table = $wpdb->prefix . 'wc_order_product_lookup';
+        if (!self::table_exists($lookup_table)) {
+            UtilsLog::log("get_orders_for_printing: required table {$lookup_table} is missing. Is WC analytics enabled?");
+            UtilsLog::message(
+                __('Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support.', 'studiou-wc-ord-print-statuses'),
+                'error'
+            );
+            return array();
+        }
+
+        $placeholders = implode(',', array_fill(0, count($order_ids), '%d'));
+
+        // Raise the GROUP_CONCAT byte cap (default 1024) so multi-category
+        // products with long names are not silently truncated. Use GREATEST
+        // so we never *lower* a setting another piece of code may rely on.
+        $wpdb->query("SET SESSION group_concat_max_len = GREATEST(@@SESSION.group_concat_max_len, 65535)");
+
+        // Non-aggregated SELECT columns are wrapped in MIN()/SUM() to remain
+        // compliant with MySQL's ONLY_FULL_GROUP_BY mode (default in 5.7+).
+        // qty uses SUM() because a single (order, product, variation) group
+        // may contain multiple lookup rows when a customer added the same
+        // variation twice without cart-merge.
+        //
+        // prod_print_status comes from `_print_status` on the order line item
+        // meta (added in 1.5.0). LEFT JOIN with COALESCE so missing meta
+        // (lazy-default items) reads as `pending-print` in the export.
+        $sql = "
             SELECT
                 `orders`.`id` AS `order_no`,
-                `t`.`name` AS `prod_cat`,
-                `products`.`post_title` AS `prod_name`,
-                `product_variations`.`post_title` AS `prod_var`,
-                `product_variations_name`.`name` AS `prod_var_type`,
-                `images`.`guid` AS `prod_img_url`,
-                `order_products`.`product_qty` AS `qty`,
-                `orders`.`billing_email` AS `email`
-            FROM (
-                    (
-                        (
-                            (
-                                (
-                                    (
-                                        (
-                                            `{$wpdb->prefix}wc_orders` `orders`
-                                        JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
-                                        ON
-                                            (
-                                                `orders`.`id` = `order_products`.`order_id`
-                                            )
-                                        )
-                                    JOIN `{$wpdb->posts}` `products`
-                                    ON
-                                        (
-                                            `products`.`ID` = `order_products`.`product_id`
-                                        )
-                                    )
-                                JOIN `{$wpdb->posts}` `product_variations`
-                                ON
-                                    (
-                                        `product_variations`.`ID` = `order_products`.`variation_id`
-                                    )
-                                LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
-                                ON
-                                    (
-                                        `product_variations_attr`.`post_id` = `product_variations`.`ID`
-                                        AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
-                                    )
-                                LEFT JOIN `{$wpdb->terms}` `product_variations_name`
-                                ON
-                                    (
-                                        `product_variations_name`.`name` = `product_variations_attr`.`meta_value`
-                                    )
-                                )
-                            LEFT JOIN `{$wpdb->postmeta}` `product_meta`
-                            ON
-                                (
-                                    `product_meta`.`post_id` = `products`.`ID` AND `product_meta`.`meta_key` = '_thumbnail_id'
-                                )
-                            )
-                        LEFT JOIN `{$wpdb->posts}` `images`
-                        ON
-                            (
-                                `images`.`ID` = `product_meta`.`meta_value` AND `images`.`post_type` = 'attachment'
-                            )
-                        JOIN `{$wpdb->term_relationships}` `tr`
-                        ON
-                            (`products`.`ID` = `tr`.`object_id`)
-                        )
-                    JOIN `{$wpdb->term_taxonomy}` `tt`
-                    ON
-                        (
-                            `tr`.`term_taxonomy_id` = `tt`.`term_taxonomy_id`
-                        )
-                    )
-                JOIN `{$wpdb->terms}` `t`
-                ON
-                    (`tt`.`term_id` = `t`.`term_id`)
-                )
-            WHERE
-                `orders`.`id` IN (" . implode(',', array_map('intval', $order_ids)) . ") 
+                GROUP_CONCAT(DISTINCT `t`.`name` ORDER BY `t`.`name` SEPARATOR ', ') AS `prod_cat`,
+                MIN(`products`.`post_title`)             AS `prod_name`,
+                MIN(`product_variations`.`post_title`)   AS `prod_var`,
+                MIN(`product_variations_name`.`name`)    AS `prod_var_type`,
+                MIN(`product_meta`.`meta_value`)         AS `prod_img_id`,
+                SUM(`order_products`.`product_qty`)      AS `qty`,
+                COALESCE(MIN(`print_status_meta`.`meta_value`), 'pending-print') AS `prod_print_status`,
+                MIN(`orders`.`billing_email`)            AS `email`
+            FROM `{$wpdb->prefix}wc_orders` `orders`
+            JOIN `{$wpdb->prefix}wc_order_product_lookup` `order_products`
+                ON `orders`.`id` = `order_products`.`order_id`
+            JOIN `{$wpdb->posts}` `products`
+                ON `products`.`ID` = `order_products`.`product_id`
+            LEFT JOIN `{$wpdb->posts}` `product_variations`
+                ON `product_variations`.`ID` = `order_products`.`variation_id`
+            LEFT JOIN `{$wpdb->postmeta}` `product_variations_attr`
+                ON `product_variations_attr`.`post_id` = `product_variations`.`ID`
+                AND `product_variations_attr`.`meta_key` = 'attribute_pa_format'
+            LEFT JOIN `{$wpdb->terms}` `product_variations_name`
+                ON `product_variations_name`.`slug` = `product_variations_attr`.`meta_value`
+            LEFT JOIN `{$wpdb->postmeta}` `product_meta`
+                ON `product_meta`.`post_id` = `products`.`ID`
+                AND `product_meta`.`meta_key` = '_thumbnail_id'
+            LEFT JOIN `{$wpdb->prefix}woocommerce_order_itemmeta` `print_status_meta`
+                ON `print_status_meta`.`order_item_id` = `order_products`.`order_item_id`
+                AND `print_status_meta`.`meta_key` = '_print_status'
+            LEFT JOIN `{$wpdb->term_relationships}` `tr`
+                ON `tr`.`object_id` = `products`.`ID`
+            LEFT JOIN `{$wpdb->term_taxonomy}` `tt`
+                ON `tt`.`term_taxonomy_id` = `tr`.`term_taxonomy_id`
                 AND `tt`.`taxonomy` = 'product_cat'
-            ORDER BY
-                `orders`.`id`
+            LEFT JOIN `{$wpdb->terms}` `t`
+                ON `t`.`term_id` = `tt`.`term_id`
+            WHERE `orders`.`id` IN ($placeholders)
+            GROUP BY
+                `orders`.`id`,
+                `order_products`.`product_id`,
+                `order_products`.`variation_id`
+            ORDER BY `orders`.`id`
         ";
-        
-        $results = $wpdb->get_results($query);
-        
-        // Error handling for database query failures
+
+        $prepared = $wpdb->prepare($sql, $order_ids);
+        $results  = $wpdb->get_results($prepared);
+
         if ($results === false) {
-            UtilsLog::log('SQL Error: ' . $wpdb->last_error);
+            UtilsLog::log('SQL error in get_orders_for_printing: ' . $wpdb->last_error);
             return array();
         }
-        
+
+        // Resolve the attachment ID to a URL via WP's API — respects
+        // offloaded-media plugins, multisite upload paths, and the
+        // wp_get_attachment_url filter chain. Normalise integer-valued
+        // qty (SUM returns DECIMAL → "2.0000") and CSV-escape every value
+        // to neutralise Excel/LibreOffice formula injection.
+        foreach ($results as $row) {
+            $attachment_id     = isset($row->prod_img_id) ? (int) $row->prod_img_id : 0;
+            $row->prod_img_url = $attachment_id ? (string) wp_get_attachment_url($attachment_id) : '';
+            unset($row->prod_img_id);
+
+            if (isset($row->qty)) {
+                $row->qty = self::format_quantity($row->qty);
+            }
+
+            foreach (get_object_vars($row) as $field => $value) {
+                $row->$field = self::csv_escape($value);
+            }
+        }
         return $results;
     }
 
     /**
-     * Set order status to 'to-print'
-     * 
-     * @param array $order_ids Array of order IDs
-     * @return int Number of orders updated
+     * Drop trailing zeros and the decimal point when the quantity is whole,
+     * keeping a sensible decimal representation when it isn't.
      */
-    public static function set_orders_to_prepare_printing($order_ids) {
-        $updated_count = 0;
-        
-        foreach ($order_ids as $order_id) {
-            UtilsLog::log('Setting status Order #' . $order_id . ' to "to-print"');
-            $order = wc_get_order($order_id);
-            if ($order) {
-                $order->update_status('to-print');
-                $order->update_meta_data('to_print_date', current_time('mysql'));
-                $order->save();
-                $updated_count++;
-                UtilsLog::log('Order #' . $order_id . ' marked as "to-print"');
-                UtilsLog::message('Order #' . $order_id . ' marked as "to-print"' );
-            } else {
-                UtilsLog::log('Order #' . $order_id . ' not found');
-            }
+    private static function format_quantity($value) {
+        if (!is_numeric($value)) {
+            return (string) $value;
         }
-        
-        return $updated_count;
+        $float = (float) $value;
+        if ((float) (int) $float === $float) {
+            return (string) (int) $float;
+        }
+        return rtrim(rtrim(sprintf('%.4F', $float), '0'), '.');
+    }
+
+    /**
+     * Defuse Excel / LibreOffice / Google Sheets formula execution by
+     * prefixing values that begin with =, +, -, @, tab, or CR with a single
+     * quote. OWASP-recommended pattern. The leading quote is hidden by
+     * Excel when displayed but disables formula evaluation.
+     *
+     * Checks the first non-whitespace character (not just $value[0]) so
+     * cells like " =CMD" — with leading whitespace some spreadsheet apps
+     * strip before evaluating — are also defused.
+     */
+    private static function csv_escape($value) {
+        if (!is_string($value)) {
+            return $value;
+        }
+        if ($value === '') {
+            return $value;
+        }
+        $trimmed = ltrim($value);
+        if ($trimmed === '') {
+            return $value;
+        }
+        $first = $trimmed[0];
+        if (in_array($first, array('=', '+', '-', '@', "\t", "\r"), true)) {
+            return "'" . $value;
+        }
+        return $value;
     }
 
     /**
-     * Set order status to 'in-print'
-     * 
-     * @param array $order_ids Array of order IDs
-     * @return int Number of orders updated
+     * Check that a table exists. `$wpdb->esc_like()` escapes the LIKE
+     * wildcards (`_`, `%`) that show up in conventional `$wpdb->prefix`
+     * values like `wp_test_` — otherwise the underscores would match any
+     * single character and could produce false positives.
+     */
+    private static function table_exists($table) {
+        global $wpdb;
+        $like  = $wpdb->esc_like($table);
+        $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like));
+        return $found === $table;
+    }
+
+    /**
+     * Bulk-set orders to "to-print", skipping orders in terminal states.
+     *
+     * @param int[] $order_ids
+     * @return int Number of orders actually updated.
+     */
+    public static function set_orders_to_prepare_printing($order_ids) {
+        return self::bulk_set_print_status($order_ids, 'to-print', 'to_print_date');
+    }
+
+    /**
+     * Bulk-set orders to "in-print", skipping orders in terminal states.
+     *
+     * @param int[] $order_ids
+     * @return int Number of orders actually updated.
      */
     public static function set_orders_to_in_printing($order_ids) {
+        return self::bulk_set_print_status($order_ids, 'in-print', 'in_print_date');
+    }
+
+    private static function bulk_set_print_status($order_ids, $status_slug, $meta_key) {
         $updated_count = 0;
-        
-        foreach ($order_ids as $order_id) {
-            $order = wc_get_order($order_id);
-            if ($order) {
-                $order->update_status('in-print');
-                $order->update_meta_data('in_print_date', current_time('mysql'));
-                $order->save();
-                $updated_count++;
-                UtilsLog::message('Order #' . $order_id . ' marked as "in-print"' );
-            }
-        }
-        
+        $skipped       = 0;
+
+        // Explanatory note for the order activity feed. Mirrors the
+        // "Marked … by … Protocol import." pattern used in the import
+        // handlers so the audit trail consistently records *how* a
+        // transition was triggered.
+        //
+        // Explicit map keyed by slug so a future contributor adding a
+        // third status doesn't accidentally inherit the wrong note via
+        // an else-implicit ternary.
+        $notes = array(
+            'to-print' => __('Marked Prepare to Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
+            'in-print' => __('Marked In Printing by bulk action.', 'studiou-wc-ord-print-statuses'),
+        );
+        $note = isset($notes[$status_slug]) ? $notes[$status_slug] : '';
+
+        foreach ((array) $order_ids as $order_id) {
+            $order_id = (int) $order_id;
+            $order    = wc_get_order($order_id);
+            if (!$order) {
+                UtilsLog::log('Order #' . $order_id . ' not found');
+                continue;
+            }
+            if ($order->has_status(self::TERMINAL_STATUSES)) {
+                UtilsLog::log('Order #' . $order_id . ' skipped (terminal status: ' . $order->get_status() . ')');
+                $skipped++;
+                continue;
+            }
+            $order->update_status($status_slug, $note);
+            $order->update_meta_data($meta_key, current_time('mysql'));
+            $order->save();
+            $updated_count++;
+            UtilsLog::log('Order #' . $order_id . ' marked as "' . $status_slug . '"');
+        }
+
+        if ($skipped > 0) {
+            UtilsLog::message(
+                sprintf(
+                    _n(
+                        '%d order skipped because its status is final (cancelled, refunded, failed, or completed).',
+                        '%d orders skipped because their status is final (cancelled, refunded, failed, or completed).',
+                        $skipped,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $skipped
+                ),
+                'warning'
+            );
+        }
         return $updated_count;
     }
 
     /**
-     * Import inprint protocol data
-     * 
-     * @param array $csv_data Parsed CSV data
-     * @return array Array with updated count and errors
+     * Import an InPrint protocol CSV.
+     *
+     * @param array $csv_data Rows produced by parse_csv().
+     * @return array{updated_count:int,errors:string[]}
      */
     public static function import_inprint_protocol($csv_data) {
         $updated_count = 0;
-        $errors = array();
+        $errors        = array();
 
-        foreach ($csv_data as $row) {
+        foreach (self::dedupe_by_order_no($csv_data) as $row) {
             if (!isset($row['order_no']) || !isset($row['externalorder']) || !isset($row['externalorderdate'])) {
                 $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
                 continue;
             }
-
             $order = wc_get_order($row['order_no']);
             if (!$order) {
+                UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' not found');
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 continue;
             }
-            
-            $order->update_status('in-print');
+            if ($order->has_status(self::TERMINAL_STATUSES)) {
+                UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' in terminal status ' . $order->get_status() . ' — skipped');
+                $errors[] = sprintf(
+                    __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
+                    $row['order_no']
+                );
+                continue;
+            }
+            $order->update_status(
+                'in-print',
+                __('Marked In Printing by InPrint Protocol import.', 'studiou-wc-ord-print-statuses')
+            );
             $order->update_meta_data('in_print_date', current_time('mysql'));
-            $order->update_meta_data('external_ref_ord_no', $row['externalorder']);
-            $order->update_meta_data('external_ref_ord_date', $row['externalorderdate']);
+            $order->update_meta_data('external_ref_ord_no', sanitize_text_field($row['externalorder']));
+            $order->update_meta_data('external_ref_ord_date', self::normalise_csv_date($row['externalorderdate']));
             $order->save();
-
             $updated_count++;
+            UtilsLog::log('import_inprint_protocol: order ' . $row['order_no'] . ' marked "in-print"');
         }
 
-        return array(
-            'updated_count' => $updated_count,
-            'errors' => $errors
-        );
+        return array('updated_count' => $updated_count, 'errors' => $errors);
     }
 
     /**
-     * Import delivered protocol data
-     * 
-     * @param array $csv_data Parsed CSV data
-     * @return array Array with updated count and errors
+     * Import a Delivered protocol CSV.
+     *
+     * @param array $csv_data Rows produced by parse_csv().
+     * @return array{updated_count:int,errors:string[]}
      */
     public static function import_delivered_protocol($csv_data) {
         $updated_count = 0;
-        $errors = array();
+        $items_advanced_total = 0;
+        $errors        = array();
+
+        // Statuses we should not silently flip to "completed". `completed`
+        // itself is excluded from this list — re-running the same delivered
+        // protocol on already-completed orders is a benign no-op transition.
+        $non_completable = array('cancelled', 'refunded', 'failed');
 
-        foreach ($csv_data as $row) {
+        $item_status_mgr = new Order_Item_Status_Manager();
+
+        foreach (self::dedupe_by_order_no($csv_data) as $row) {
             if (!isset($row['order_no'])) {
                 $errors[] = __('Invalid row format in CSV.', 'studiou-wc-ord-print-statuses');
                 continue;
             }
-           
             $order = wc_get_order($row['order_no']);
             if (!$order) {
+                UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' not found');
                 $errors[] = sprintf(__('Order %s not found.', 'studiou-wc-ord-print-statuses'), $row['order_no']);
                 continue;
             }
-
-            $order->update_status('completed');
+            if ($order->has_status($non_completable)) {
+                UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' in terminal status ' . $order->get_status() . ' — skipped');
+                $errors[] = sprintf(
+                    __('Order %s is in a final state and was not updated.', 'studiou-wc-ord-print-statuses'),
+                    $row['order_no']
+                );
+                continue;
+            }
+            // Advance every still-pending or in-print item to done-print so
+            // the completion guard installed by Order_Item_Status_Manager
+            // is satisfied. Items already in skip-print or done-print are
+            // untouched.
+            $advanced = $item_status_mgr->advance_remaining_to_done($order);
+            if ($advanced > 0) {
+                $items_advanced_total += $advanced;
+                $order->add_order_note(sprintf(
+                    /* translators: %d is the number of items advanced. */
+                    _n(
+                        '%d line item advanced to "Done Print" by Delivered Protocol import.',
+                        '%d line items advanced to "Done Print" by Delivered Protocol import.',
+                        $advanced,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $advanced
+                ));
+            }
+            $order->update_status(
+                'completed',
+                __('Marked completed by Delivered Protocol import.', 'studiou-wc-ord-print-statuses')
+            );
             $order->update_meta_data('external_ref_ord_delivered', current_time('mysql'));
             $order->save();
-
             $updated_count++;
+            UtilsLog::log('import_delivered_protocol: order ' . $row['order_no'] . ' marked "completed" (' . $advanced . ' items advanced)');
         }
 
-        return array(
-            'updated_count' => $updated_count,
-            'errors' => $errors
-        );
+        if ($items_advanced_total > 0) {
+            UtilsLog::message(
+                sprintf(
+                    /* translators: %d total items advanced across all orders in this import. */
+                    _n(
+                        '%d line item advanced to "Done Print" to satisfy completion guard.',
+                        '%d line items advanced to "Done Print" to satisfy completion guard.',
+                        $items_advanced_total,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $items_advanced_total
+                ),
+                'info'
+            );
+        }
+
+        return array('updated_count' => $updated_count, 'errors' => $errors);
     }
 
     /**
-     * Parse CSV file
-     * 
-     * @param string $file_path Path to CSV file
-     * @return array|false Parsed CSV data or false on error
+     * Parse a CSV file into an array of associative rows.
+     * Headers are normalised (lowercased, whitespace stripped) so files prepared
+     * to either the legacy spec casing ("Order No") or the documented lowercase
+     * form ("order_no") both work. A leading UTF-8 BOM is stripped.
+     *
+     * @param string $file_path
+     * @return array Empty array on failure.
      */
     public static function parse_csv($file_path) {
         $csv_data = array();
-        if (($handle = fopen($file_path, "r")) !== FALSE) {
-            $headers = fgetcsv($handle, 1000, ",");
-            while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) {
+        $handle   = fopen($file_path, 'r');
+        if ($handle === false) {
+            UtilsLog::log('parse_csv: failed to open ' . $file_path);
+            return $csv_data;
+        }
+        try {
+            // Strip a leading UTF-8 BOM if present so the first header parses
+            // cleanly. The plugin's own exports include a BOM (for Excel),
+            // and Notepad-saved CSVs frequently include one.
+            $first_bytes = fread($handle, 3);
+            if ($first_bytes !== "\xEF\xBB\xBF") {
+                rewind($handle);
+            }
+            // Explicit empty-string escape suppresses the PHP 8.4 deprecation
+            // notice for the implicit '\\' default.
+            $headers = fgetcsv($handle, 0, ',', '"', '');
+            if (!is_array($headers) || empty(array_filter($headers))) {
+                UtilsLog::log('parse_csv: header row missing or empty');
+                return $csv_data;
+            }
+            $headers = array_map(array(__CLASS__, 'normalise_header'), $headers);
+            // Reject CSVs whose headers collide after normalisation (e.g.,
+            // "Order No" and "order_no" both → "order_no"). array_combine
+            // would silently let the later column's data overwrite the
+            // earlier — losing one column's values entirely.
+            if (count($headers) !== count(array_unique($headers))) {
+                UtilsLog::log('parse_csv: duplicate column headers after normalisation: ' . implode(', ', $headers));
+                return $csv_data;
+            }
+            while (($data = fgetcsv($handle, 0, ',', '"', '')) !== false) {
+                if (count($data) !== count($headers)) {
+                    continue;
+                }
                 $csv_data[] = array_combine($headers, $data);
             }
+        } finally {
             fclose($handle);
         }
         return $csv_data;
     }
-}
+
+    private static function normalise_header($header) {
+        $header = strtolower(trim((string) $header));
+        // Strip any UTF-8 BOM that survived the file-level guard.
+        $header = preg_replace('/^\xEF\xBB\xBF/', '', $header);
+        // Map "order no" → "order_no" by collapsing internal whitespace to underscore.
+        $header = preg_replace('/\s+/', '_', $header);
+        return $header;
+    }
+
+    private static function dedupe_by_order_no($rows) {
+        $seen   = array();
+        $unique = array();
+        foreach ($rows as $row) {
+            $raw_key = isset($row['order_no']) ? trim((string) $row['order_no']) : '';
+            if ($raw_key === '') {
+                continue;
+            }
+            // Case-insensitive dedup so alphabetic order numbers
+            // ("ord-12345" vs "ORD-12345") collapse to a single row.
+            $key = strtolower($raw_key);
+            if (isset($seen[$key])) {
+                continue;
+            }
+            $seen[$key] = true;
+            $unique[]   = $row;
+        }
+        return $unique;
+    }
+
+    /**
+     * Normalise a free-form date string into a MySQL datetime in WP local time.
+     * Returns '' (and logs) on parse failure so the UI does not silently
+     * carry unparseable raw values through the round-trip.
+     *
+     * Avoids strtotime() entirely. WordPress sets the PHP timezone to UTC
+     * very early in its boot (date_default_timezone_set('UTC')), so
+     * strtotime() interprets timezone-less inputs as UTC. Combining that
+     * with wp_date() — which expects a UTC timestamp and formats in WP
+     * timezone — shifted "2026-05-12 14:30" forward by the WP offset.
+     *
+     * Two paths:
+     * - Fast: ISO-like "YYYY-MM-DD[T ]HH:MM[:SS]" with no explicit timezone
+     *   → pure string reformat, no shift. This is the common shape of
+     *   what the print shop sends.
+     * - Fallback: DateTimeImmutable($raw, wp_timezone()) — interprets
+     *   timezone-less input as WP local; explicit-timezone input is honoured
+     *   by the constructor (second arg ignored in that case) and then
+     *   converted back to WP timezone via setTimezone() so the output is
+     *   always WP-local.
+     */
+    private static function normalise_csv_date($raw) {
+        $raw = sanitize_text_field((string) $raw);
+        if ($raw === '') {
+            return '';
+        }
+        // Fast path — common ISO-ish shape with no explicit timezone.
+        // checkdate() + h/m/s bounds reject structurally-valid but
+        // semantically-out-of-range inputs (e.g., "2026-13-45T25:99")
+        // so they fall through to the DateTimeImmutable path, which
+        // will throw and route us to the catch.
+        if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/', $raw, $m)) {
+            $year   = (int) $m[1];
+            $month  = (int) $m[2];
+            $day    = (int) $m[3];
+            $hour   = (int) $m[4];
+            $minute = (int) $m[5];
+            $second = isset($m[6]) ? (int) $m[6] : 0;
+            if (checkdate($month, $day, $year) && $hour <= 23 && $minute <= 59 && $second <= 59) {
+                return $m[1] . '-' . $m[2] . '-' . $m[3] . ' ' . $m[4] . ':' . $m[5] . ':' . sprintf('%02d', $second);
+            }
+        }
+        // Fallback — let DateTimeImmutable handle locale dates and explicit
+        // timezones. Strict: throws ValueError on garbage (e.g. "foo").
+        try {
+            $dt = new DateTimeImmutable($raw, wp_timezone());
+            $dt = $dt->setTimezone(wp_timezone());
+            return $dt->format('Y-m-d H:i:s');
+        } catch (Exception $e) {
+            UtilsLog::log('normalise_csv_date: could not parse "' . $raw . '"');
+            return '';
+        }
+    }
+}

+ 169 - 53
studiou-wc-ord-print-statuses/includes/class-import-manager.php

@@ -1,19 +1,26 @@
 <?php
-//  Handles import functionality for InPrint and Delivered protocols.
-// visualized
+// Admin UI + handlers for importing the InPrint and Delivered protocol CSVs.
+
+defined('ABSPATH') || exit;
+
 class Import_Manager {
     public function __construct() {
         add_action('admin_menu', array($this, 'add_import_submenu'));
-        add_action('admin_post_import_inprint_protocol', array($this, 'handle_import_inprint_protocol'));
+        add_action('admin_post_import_inprint_protocol',   array($this, 'handle_import_inprint_protocol'));
         add_action('admin_post_import_delivered_protocol', array($this, 'handle_import_delivered_protocol'));
     }
 
     public function add_import_submenu() {
+        $title = __('Import Protocols', 'studiou-wc-ord-print-statuses');
+        // Use edit_shop_orders so Shop Managers (the typical operators of
+        // the print pipeline) can run imports. The bulk actions on the
+        // orders list use the same capability — keeping the two paths in
+        // sync is the goal.
         add_submenu_page(
             'woocommerce',
-            __('Import Protocols', 'studiou-wc-ord-print-statuses'),
-            __('Import Protocols', 'studiou-wc-ord-print-statuses'),
-            'manage_woocommerce',
+            $title,
+            $title,
+            'edit_shop_orders',
             'studiou-import-protocols',
             array($this, 'render_import_page')
         );
@@ -23,87 +30,196 @@ class Import_Manager {
         ?>
         <div class="wrap">
             <h1><?php echo esc_html(get_admin_page_title()); ?></h1>
-            
-            <h2><?php _e('Import InPrint Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+
+            <h2><?php esc_html_e('Import InPrint Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+            <p>
+                <?php
+                printf(
+                    /* translators: list of required CSV header names. */
+                    esc_html__('Required CSV headers: %s', 'studiou-wc-ord-print-statuses'),
+                    '<code>order_no</code>, <code>externalorder</code>, <code>externalorderdate</code>'
+                );
+                ?>
+            </p>
             <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data">
                 <input type="hidden" name="action" value="import_inprint_protocol">
                 <?php wp_nonce_field('import_inprint_protocol', 'import_inprint_protocol_nonce'); ?>
-                <input type="file" name="inprint_csv" accept=".csv" required>
+                <input type="file" name="inprint_csv" accept=".csv,text/csv" required>
                 <?php submit_button(__('Import InPrint Protocol', 'studiou-wc-ord-print-statuses')); ?>
             </form>
 
-            <h2><?php _e('Import Delivered Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+            <h2><?php esc_html_e('Import Delivered Protocol', 'studiou-wc-ord-print-statuses'); ?></h2>
+            <p>
+                <?php
+                printf(
+                    /* translators: list of required CSV header names. */
+                    esc_html__('Required CSV headers: %s', 'studiou-wc-ord-print-statuses'),
+                    '<code>order_no</code>'
+                );
+                ?>
+            </p>
             <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data">
                 <input type="hidden" name="action" value="import_delivered_protocol">
                 <?php wp_nonce_field('import_delivered_protocol', 'import_delivered_protocol_nonce'); ?>
-                <input type="file" name="delivered_csv" accept=".csv" required>
+                <input type="file" name="delivered_csv" accept=".csv,text/csv" required>
                 <?php submit_button(__('Import Delivered Protocol', 'studiou-wc-ord-print-statuses')); ?>
             </form>
         </div>
         <?php
     }
 
+    /**
+     * Map from admin-post action name → [file field, nonce field/action, DB handler callable].
+     * Adding a new protocol is a single-entry change here.
+     */
+    private function protocol_dispatch() {
+        return array(
+            'import_inprint_protocol' => array(
+                'file_field' => 'inprint_csv',
+                'nonce'      => 'import_inprint_protocol_nonce',
+                'handler'    => array('Studiou_DB_Manager', 'import_inprint_protocol'),
+            ),
+            'import_delivered_protocol' => array(
+                'file_field' => 'delivered_csv',
+                'nonce'      => 'import_delivered_protocol_nonce',
+                'handler'    => array('Studiou_DB_Manager', 'import_delivered_protocol'),
+            ),
+        );
+    }
+
     public function handle_import_inprint_protocol() {
-        if (!current_user_can('manage_woocommerce')) {
-            wp_die(__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
-        }
+        $this->run_import('import_inprint_protocol');
+    }
 
-        check_admin_referer('import_inprint_protocol', 'import_inprint_protocol_nonce');
+    public function handle_import_delivered_protocol() {
+        $this->run_import('import_delivered_protocol');
+    }
 
-        if (!isset($_FILES['inprint_csv'])) {
-            wp_die(__('No file uploaded.', 'studiou-wc-ord-print-statuses'));
+    /**
+     * Common pipeline: capability check → nonce → file validation → parse → dispatch → redirect.
+     * Every non-success branch returns immediately via redirect_back() so static
+     * analysis can prove that subsequent code is only reached on the happy path.
+     */
+    private function run_import($action) {
+        $dispatch = $this->protocol_dispatch();
+        if (!isset($dispatch[$action])) {
+            return $this->redirect_back();
         }
+        $cfg = $dispatch[$action];
 
-        $file = $_FILES['inprint_csv'];
-        $csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
-
-        if (!$csv_data) {
-            wp_die(__('Error parsing CSV file.', 'studiou-wc-ord-print-statuses'));
+        if (!current_user_can('edit_shop_orders')) {
+            wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
         }
+        check_admin_referer($action, $cfg['nonce']);
 
-        // Process the import using DB Manager
-        $result = Studiou_DB_Manager::import_inprint_protocol($csv_data);
-        $updated_count = $result['updated_count'];
-        $errors = $result['errors'];
+        $file = $this->validated_upload($cfg['file_field']);
+        if ($file === null) {
+            return $this->redirect_back();
+        }
 
-        $message = sprintf(__('%d orders updated successfully.', 'studiou-wc-ord-print-statuses'), $updated_count);
-        if (!empty($errors)) {
-            $message .= ' ' . sprintf(__('%d errors occurred: %s', 'studiou-wc-ord-print-statuses'), count($errors), implode(', ', $errors));
+        $csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
+        if (empty($csv_data)) {
+            UtilsLog::message(
+                __('CSV file could not be parsed or contained no data rows.', 'studiou-wc-ord-print-statuses'),
+                'error'
+            );
+            return $this->redirect_back();
         }
 
-        wp_redirect(add_query_arg('message', urlencode($message), admin_url('admin.php?page=studiou-import-protocols')));
-        exit;
+        $handler = $cfg['handler'];
+        $result  = $handler($csv_data);
+        $this->emit_import_summary($result);
+        return $this->redirect_back();
     }
 
-    public function handle_import_delivered_protocol() {
-        if (!current_user_can('manage_woocommerce')) {
-            wp_die(__('You do not have sufficient permissions to access this page.', 'studiou-wc-ord-print-statuses'));
+    /**
+     * Validate the file upload superglobal entry. Emits a notice and returns null on failure.
+     *
+     * @return array|null
+     */
+    private function validated_upload($field) {
+        if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) {
+            UtilsLog::message(__('No file uploaded.', 'studiou-wc-ord-print-statuses'), 'error');
+            return null;
         }
-
-        check_admin_referer('import_delivered_protocol', 'import_delivered_protocol_nonce');
-
-        if (!isset($_FILES['delivered_csv'])) {
-            wp_die(__('No file uploaded.', 'studiou-wc-ord-print-statuses'));
+        $file = $_FILES[$field];
+        if (isset($file['error']) && $file['error'] !== UPLOAD_ERR_OK) {
+            UtilsLog::message(
+                sprintf(
+                    /* translators: %d is the PHP upload error code. */
+                    __('File upload failed (error code %d).', 'studiou-wc-ord-print-statuses'),
+                    (int) $file['error']
+                ),
+                'error'
+            );
+            return null;
         }
-
-        $file = $_FILES['delivered_csv'];
-        $csv_data = Studiou_DB_Manager::parse_csv($file['tmp_name']);
-
-        if (!$csv_data) {
-            wp_die(__('Error parsing CSV file.', 'studiou-wc-ord-print-statuses'));
+        if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
+            UtilsLog::message(__('Uploaded file is not accessible.', 'studiou-wc-ord-print-statuses'), 'error');
+            return null;
+        }
+        // Defense-in-depth: the HTML `accept` attribute is a client-side hint
+        // and can be bypassed. Verify the uploaded file is named *.csv.
+        $name = isset($file['name']) ? (string) $file['name'] : '';
+        $ext  = strtolower(pathinfo($name, PATHINFO_EXTENSION));
+        if ($ext !== 'csv') {
+            UtilsLog::message(__('Only .csv files are accepted.', 'studiou-wc-ord-print-statuses'), 'error');
+            return null;
         }
+        return $file;
+    }
 
-        // Process the import using DB Manager
-        $result = Studiou_DB_Manager::import_delivered_protocol($csv_data);
-        $updated_count = $result['updated_count'];
-        $errors = $result['errors'];
+    private function emit_import_summary($result) {
+        $updated = isset($result['updated_count']) ? (int) $result['updated_count'] : 0;
+        $errors  = isset($result['errors']) && is_array($result['errors']) ? $result['errors'] : array();
+
+        // Skip the "0 orders updated" notice when nothing actually changed —
+        // the per-error notice below is more informative on its own and
+        // matches the pattern Bulk_Actions_Manager::notify_moved_count uses.
+        if ($updated > 0) {
+            UtilsLog::message(
+                sprintf(
+                    _n(
+                        '%d order updated successfully.',
+                        '%d orders updated successfully.',
+                        $updated,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $updated
+                ),
+                'success'
+            );
+        } elseif (empty($errors)) {
+            // Both counters zero: the CSV parsed and had rows, but
+            // dedupe_by_order_no filtered everything out — every row had
+            // an empty order_no column. Surface this so the operator
+            // isn't left wondering whether the import ran.
+            UtilsLog::message(
+                __('The CSV file contained no actionable rows (empty order_no column).', 'studiou-wc-ord-print-statuses'),
+                'info'
+            );
+        }
 
-        $message = sprintf(__('%d orders updated successfully.', 'studiou-wc-ord-print-statuses'), $updated_count);
         if (!empty($errors)) {
-            $message .= ' ' . sprintf(__('%d errors occurred: %s', 'studiou-wc-ord-print-statuses'), count($errors), implode(', ', $errors));
+            // Dedupe repeated error strings (e.g., "Invalid row format in
+            // CSV." 100×) so the notice stays readable. The count is the
+            // total number of errors, not the number of unique messages.
+            $total  = count($errors);
+            $unique = array_values(array_unique($errors));
+            UtilsLog::message(
+                sprintf(
+                    /* translators: 1: error count, 2: comma-separated error messages. */
+                    __('%1$d errors occurred: %2$s', 'studiou-wc-ord-print-statuses'),
+                    $total,
+                    esc_html(implode(', ', $unique))
+                ),
+                'error'
+            );
         }
+    }
 
-        wp_redirect(add_query_arg('message', urlencode($message), admin_url('admin.php?page=studiou-import-protocols')));
+    private function redirect_back() {
+        wp_safe_redirect(admin_url('admin.php?page=studiou-import-protocols'));
         exit;
     }
-}
+}

+ 133 - 66
studiou-wc-ord-print-statuses/includes/class-order-fields-manager.php

@@ -1,66 +1,133 @@
-<?php
-// Manages custom order fields.
-// visualized
-
-class Order_Fields_Manager {
-    public function __construct() {
-        add_action('woocommerce_admin_order_data_after_order_details', array($this, 'display_custom_order_fields'));
-        add_action('woocommerce_process_shop_order_meta', array($this, 'save_custom_order_fields'));
-    }
-
-    public function display_custom_order_fields($order) {
-        ?>
-        <div class="order_data_column">
-            <h4><?php _e('Print Information', 'studiou-wc-ord-print-statuses'); ?></h4>
-            <?php
-            woocommerce_wp_text_input(array(
-                'id' => 'to_print_date',
-                'label' => __('Prepare To Print Date', 'studiou-wc-ord-print-statuses'),
-                'type' => 'datetime-local',
-                'value' => $order->get_meta('to_print_date'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            woocommerce_wp_text_input(array(
-                'id' => 'in_print_date',
-                'label' => __('In Print Date', 'studiou-wc-ord-print-statuses'),
-                'type' => 'datetime-local',
-                'value' => $order->get_meta('in_print_date'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            woocommerce_wp_text_input(array(
-                'id' => 'external_ref_ord_no',
-                'label' => __('External Order Number', 'studiou-wc-ord-print-statuses'),
-                'value' => $order->get_meta('external_ref_ord_no'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            woocommerce_wp_text_input(array(
-                'id' => 'external_ref_ord_delivered',
-                'label' => __('External Order Delivered', 'studiou-wc-ord-print-statuses'),
-                'type' => 'datetime-local',
-                'value' => $order->get_meta('external_ref_ord_delivered'),
-                'wrapper_class' => 'form-field-wide'
-            ));
-            ?>
-        </div>
-        <?php
-    }
-
-    public function save_custom_order_fields($order_id) {
-        $order = wc_get_order($order_id);
-        
-        if (isset($_POST['to_print_date'])) {
-            $order->update_meta_data('to_print_date', sanitize_text_field($_POST['to_print_date']));
-        }
-        if (isset($_POST['in_print_date'])) {
-            $order->update_meta_data('in_print_date', sanitize_text_field($_POST['in_print_date']));
-        }
-        if (isset($_POST['external_ref_ord_no'])) {
-            $order->update_meta_data('external_ref_ord_no', sanitize_text_field($_POST['external_ref_ord_no']));
-        }
-        if (isset($_POST['external_ref_ord_delivered'])) {
-            $order->update_meta_data('external_ref_ord_delivered', sanitize_text_field($_POST['external_ref_ord_delivered']));
-        }
-        
-        $order->save();
-    }
-}
+<?php
+// Manages custom order fields rendered in the "Print Information" panel.
+
+defined('ABSPATH') || exit;
+
+class Order_Fields_Manager {
+    /**
+     * Meta key → translatable label for fields rendered as `datetime-local`.
+     * The render and save loops both walk this map.
+     */
+    private static function datetime_fields() {
+        return array(
+            'to_print_date'              => __('Prepare To Print Date', 'studiou-wc-ord-print-statuses'),
+            'in_print_date'              => __('In Print Date', 'studiou-wc-ord-print-statuses'),
+            'external_ref_ord_date'      => __('External Order Date', 'studiou-wc-ord-print-statuses'),
+            'external_ref_ord_delivered' => __('External Order Delivered', 'studiou-wc-ord-print-statuses'),
+        );
+    }
+
+    /**
+     * Meta key → translatable label for plain text fields.
+     */
+    private static function text_fields() {
+        return array(
+            'external_ref_ord_no' => __('External Order Number', 'studiou-wc-ord-print-statuses'),
+        );
+    }
+
+    public function __construct() {
+        add_action('woocommerce_admin_order_data_after_order_details', array($this, 'display_custom_order_fields'));
+        add_action('woocommerce_process_shop_order_meta', array($this, 'save_custom_order_fields'));
+    }
+
+    public function display_custom_order_fields($order) {
+        ?>
+        <div class="order_data_column">
+            <h4><?php esc_html_e('Print Information', 'studiou-wc-ord-print-statuses'); ?></h4>
+            <?php
+            foreach (self::datetime_fields() as $key => $label) {
+                woocommerce_wp_text_input(array(
+                    'id'            => $key,
+                    'label'         => $label,
+                    'type'          => 'datetime-local',
+                    'value'         => $this->mysql_to_datetime_local($order->get_meta($key)),
+                    'wrapper_class' => 'form-field-wide',
+                ));
+            }
+            foreach (self::text_fields() as $key => $label) {
+                woocommerce_wp_text_input(array(
+                    'id'            => $key,
+                    'label'         => $label,
+                    'value'         => $order->get_meta($key),
+                    'wrapper_class' => 'form-field-wide',
+                ));
+            }
+            ?>
+        </div>
+        <?php
+    }
+
+    public function save_custom_order_fields($order_id) {
+        if (!current_user_can('edit_shop_order', $order_id) && !current_user_can('edit_shop_orders')) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+
+        foreach (array_keys(self::datetime_fields()) as $field) {
+            if (isset($_POST[$field])) {
+                $order->update_meta_data($field, $this->datetime_local_to_mysql($_POST[$field]));
+            }
+        }
+        foreach (array_keys(self::text_fields()) as $field) {
+            if (isset($_POST[$field])) {
+                $order->update_meta_data($field, sanitize_text_field(wp_unslash($_POST[$field])));
+            }
+        }
+        $order->save();
+    }
+
+    /**
+     * Convert a MySQL datetime string (as written by current_time('mysql')) to
+     * the HTML5 `datetime-local` format. Done as pure string reformatting so
+     * the value's timezone is preserved — current_time('mysql') already uses
+     * WP local time, and the browser interprets datetime-local as local time.
+     * Using strtotime()/date() here would re-interpret the value in the server
+     * timezone and drift on UTC-server / non-UTC-site configurations.
+     */
+    private function mysql_to_datetime_local($value) {
+        $value = trim((string) $value);
+        if ($value === '') {
+            return '';
+        }
+        // Accepts 'YYYY-MM-DD HH:MM[:SS]' or 'YYYY-MM-DDTHH:MM[:SS]'.
+        if (preg_match('/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/', $value, $m)) {
+            return $m[1] . 'T' . $m[2];
+        }
+        return '';
+    }
+
+    /**
+     * Convert an HTML5 datetime-local form value to a MySQL datetime string.
+     * No timezone conversion — the form value and the stored value are both
+     * treated as WP local time (matching current_time('mysql')).
+     *
+     * Browsers prevent out-of-range values on `<input type="datetime-local">`
+     * but a power user editing via DevTools or a script POSTing directly to
+     * the order edit URL could submit `2026-13-45T25:99`. The regex matches
+     * structurally; checkdate() + minute/hour bounds catch the semantics.
+     */
+    private function datetime_local_to_mysql($value) {
+        $value = sanitize_text_field(wp_unslash($value));
+        if ($value === '') {
+            return '';
+        }
+        if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/', $value, $m)) {
+            return '';
+        }
+        list(, $year, $month, $day, $hour, $minute) = $m;
+        $second = isset($m[6]) ? $m[6] : '00';
+
+        if (!checkdate((int) $month, (int) $day, (int) $year)) {
+            return '';
+        }
+        if ((int) $hour > 23 || (int) $minute > 59 || (int) $second > 59) {
+            return '';
+        }
+
+        return $year . '-' . $month . '-' . $day . ' ' . $hour . ':' . $minute . ':' . $second;
+    }
+}

+ 349 - 0
studiou-wc-ord-print-statuses/includes/class-order-item-status-manager.php

@@ -0,0 +1,349 @@
+<?php
+/**
+ * Per-order-item print status — UI, persistence, propagation, completion guard.
+ *
+ * See docs/feature-order-item-status-analysis.md for the design rationale.
+ *
+ * Coupling rules (one-way, order → items):
+ * - Order → wc-in-print: items in `pending-print` advance to `in-print`.
+ *   Other item statuses are untouched.
+ * - Order → wc-completed: gated, not propagating. Blocked when any product
+ *   line item is still `pending-print` or `in-print`.
+ * - All other order-status transitions: no automatic item change.
+ * - Item status changed manually: never propagates to the order.
+ */
+
+defined('ABSPATH') || exit;
+
+class Order_Item_Status_Manager {
+    const META_KEY = '_print_status';
+
+    const STATUS_PENDING = 'pending-print';
+    const STATUS_IN      = 'in-print';
+    const STATUS_DONE    = 'done-print';
+    const STATUS_SKIP    = 'skip-print';
+
+    /**
+     * Statuses that satisfy the completion guard. Anything not in this list
+     * blocks the order from transitioning to `wc-completed`.
+     */
+    const STATUSES_THAT_SATISFY_COMPLETION = array(
+        self::STATUS_DONE,
+        self::STATUS_SKIP,
+    );
+
+    /**
+     * Re-entrancy flag for the completion-guard revert path. The revert calls
+     * $order->update_status() which fires woocommerce_order_status_changed
+     * again — without this flag we'd recurse indefinitely on a blocked
+     * completion attempt.
+     */
+    private static $inside_self_revert = false;
+
+    public function __construct() {
+        add_action('woocommerce_admin_order_item_headers', array($this, 'render_column_header'));
+        add_action('woocommerce_admin_order_item_values',  array($this, 'render_column_value'), 10, 3);
+        add_action('woocommerce_process_shop_order_meta',  array($this, 'save_item_statuses_from_post'));
+        add_action('woocommerce_order_status_changed',     array($this, 'on_order_status_changed'), 5, 4);
+    }
+
+    public static function valid_statuses() {
+        return array(
+            self::STATUS_PENDING,
+            self::STATUS_IN,
+            self::STATUS_DONE,
+            self::STATUS_SKIP,
+        );
+    }
+
+    /**
+     * Translatable labels for the four statuses. Built lazily so the
+     * translation lookup happens after the text domain is loaded.
+     */
+    public static function labels() {
+        return array(
+            self::STATUS_PENDING => __('Pending Print', 'studiou-wc-ord-print-statuses'),
+            self::STATUS_IN      => __('In Print',      'studiou-wc-ord-print-statuses'),
+            self::STATUS_DONE    => __('Done Print',    'studiou-wc-ord-print-statuses'),
+            self::STATUS_SKIP    => __('Skip Print',    'studiou-wc-ord-print-statuses'),
+        );
+    }
+
+    /**
+     * Return the item's current print status. Missing meta defaults to
+     * STATUS_PENDING — lazy default avoids a one-time migration over all
+     * existing orders.
+     */
+    public function get_status($item) {
+        if (!$item instanceof WC_Order_Item_Product) {
+            return self::STATUS_PENDING;
+        }
+        $value = (string) $item->get_meta(self::META_KEY);
+        if (in_array($value, self::valid_statuses(), true)) {
+            return $value;
+        }
+        return self::STATUS_PENDING;
+    }
+
+    /**
+     * Persist a new status for a single item. Does NOT call $item->save() —
+     * the caller is responsible for persistence (so callers that batch
+     * multiple item updates can save in one round-trip).
+     *
+     * @param WC_Order_Item_Product $item
+     * @param string                $status One of the four constants.
+     * @param bool                  $audit  If true, append an order note describing the transition.
+     * @return bool true if the meta was updated (or already at the requested value), false if rejected.
+     */
+    public function set_status($item, $status, $audit = true) {
+        if (!$item instanceof WC_Order_Item_Product) {
+            return false;
+        }
+        if (!in_array($status, self::valid_statuses(), true)) {
+            return false;
+        }
+
+        $current = $this->get_status($item);
+        if ($current === $status) {
+            return true; // no-op
+        }
+
+        $item->update_meta_data(self::META_KEY, $status);
+
+        if ($audit) {
+            $order = $item->get_order();
+            if ($order) {
+                $labels = self::labels();
+                $order->add_order_note(sprintf(
+                    /* translators: 1: item ID, 2: product name, 3: old status label, 4: new status label */
+                    __('Item #%1$d (%2$s): %3$s → %4$s.', 'studiou-wc-ord-print-statuses'),
+                    $item->get_id(),
+                    wp_strip_all_tags((string) $item->get_name()),
+                    $labels[$current],
+                    $labels[$status]
+                ));
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Inspect every product line item on the order. Returns an array with
+     * keys 'ready' (bool) and 'blocking' (array of WC_Order_Item_Product
+     * instances still in pending-print or in-print).
+     *
+     * Non-product items (shipping, fees, coupons, taxes, refunds) are
+     * ignored — they don't carry a print status and never block.
+     *
+     * @param WC_Order $order
+     * @return array{ready:bool, blocking:WC_Order_Item_Product[]}
+     */
+    public function is_order_ready_for_completion($order) {
+        $blocking = array();
+        foreach ($order->get_items() as $item) {
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            $status = $this->get_status($item);
+            if (!in_array($status, self::STATUSES_THAT_SATISFY_COMPLETION, true)) {
+                $blocking[] = $item;
+            }
+        }
+        return array(
+            'ready'    => empty($blocking),
+            'blocking' => $blocking,
+        );
+    }
+
+    /**
+     * Advance every product item currently in `pending-print` to `in-print`.
+     * Used by the propagation listener on order → wc-in-print transitions.
+     * Items in `done-print` or `skip-print` are untouched.
+     *
+     * @param WC_Order $order
+     * @return int Number of items advanced.
+     */
+    public function advance_pending_to_in($order) {
+        $count = 0;
+        foreach ($order->get_items() as $item) {
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            if ($this->get_status($item) !== self::STATUS_PENDING) {
+                continue;
+            }
+            // Skip per-item audit notes — the caller adds a single summary
+            // note ("N items advanced to In Print …") for the whole batch.
+            $this->set_status($item, self::STATUS_IN, false);
+            $item->save();
+            $count++;
+        }
+        return $count;
+    }
+
+    /**
+     * Advance every product item still in `pending-print` or `in-print` to
+     * `done-print`. Used by Delivered Protocol import before flipping the
+     * order to `wc-completed` so the completion guard is satisfied.
+     *
+     * @param WC_Order $order
+     * @return int Number of items advanced.
+     */
+    public function advance_remaining_to_done($order) {
+        $count = 0;
+        foreach ($order->get_items() as $item) {
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            $current = $this->get_status($item);
+            if (!in_array($current, array(self::STATUS_PENDING, self::STATUS_IN), true)) {
+                continue;
+            }
+            $this->set_status($item, self::STATUS_DONE, false);
+            $item->save();
+            $count++;
+        }
+        return $count;
+    }
+
+    /* ---------------------------------------------------------------- *
+     * UI rendering
+     * ---------------------------------------------------------------- */
+
+    /**
+     * Render the new column header in the order-items table. Fires inside
+     * `<thead>` for the product items section of the order edit screen.
+     */
+    public function render_column_header(/* $order */) {
+        echo '<th class="item_print_status sortable" data-sort="string-ins">'
+            . esc_html__('Print Status', 'studiou-wc-ord-print-statuses')
+            . '</th>';
+    }
+
+    /**
+     * Render the per-row cell. Fires once per product line item in the
+     * order-items table. Non-product items are handled by their own
+     * templates, which don't fire this hook.
+     */
+    public function render_column_value($product, $item, $item_id) {
+        if (!$item instanceof WC_Order_Item_Product) {
+            echo '<td class="item_print_status">—</td>';
+            return;
+        }
+        $current = $this->get_status($item);
+        $labels  = self::labels();
+        echo '<td class="item_print_status" data-print-status="' . esc_attr($current) . '">';
+        echo '<select name="print_status[' . esc_attr((string) $item_id) . ']" class="studiou-print-status-select">';
+        foreach (self::valid_statuses() as $slug) {
+            printf(
+                '<option value="%1$s"%2$s>%3$s</option>',
+                esc_attr($slug),
+                selected($slug, $current, false),
+                esc_html($labels[$slug])
+            );
+        }
+        echo '</select>';
+        echo '</td>';
+    }
+
+    /* ---------------------------------------------------------------- *
+     * Save handler
+     * ---------------------------------------------------------------- */
+
+    /**
+     * Persist any submitted item-status changes from $_POST['print_status'].
+     * Hooked to `woocommerce_process_shop_order_meta` (fires after WC's own
+     * item processing on form submit). HPOS- and CPT-compatible.
+     */
+    public function save_item_statuses_from_post($order_id) {
+        if (!current_user_can('edit_shop_order', $order_id) && !current_user_can('edit_shop_orders')) {
+            return;
+        }
+        if (empty($_POST['print_status']) || !is_array($_POST['print_status'])) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+        $valid_statuses = self::valid_statuses();
+        foreach (wp_unslash($_POST['print_status']) as $item_id => $new_status) {
+            $new_status = sanitize_text_field((string) $new_status);
+            if (!in_array($new_status, $valid_statuses, true)) {
+                continue;
+            }
+            $item = $order->get_item((int) $item_id);
+            if (!$item instanceof WC_Order_Item_Product) {
+                continue;
+            }
+            if ($this->set_status($item, $new_status)) {
+                $item->save();
+            }
+        }
+    }
+
+    /* ---------------------------------------------------------------- *
+     * Combined propagation + completion guard listener
+     * ---------------------------------------------------------------- */
+
+    /**
+     * Hooked to `woocommerce_order_status_changed` at priority 5.
+     *
+     * Two behaviours:
+     *   1. Propagation: on `→ in-print`, advance pending items to in-print.
+     *      Includes manual admin dropdown changes, bulk actions, and the
+     *      InPrint Protocol import — they all flow through update_status.
+     *   2. Guard: on `→ completed`, check that every product item is in
+     *      done-print or skip-print. If not, revert the status to $from
+     *      and surface an admin notice.
+     */
+    public function on_order_status_changed($order_id, $from, $to, $order) {
+        if (self::$inside_self_revert) {
+            return;
+        }
+
+        if ($to === 'in-print') {
+            $advanced = $this->advance_pending_to_in($order);
+            if ($advanced > 0) {
+                $order->add_order_note(sprintf(
+                    /* translators: %d is the number of items advanced. */
+                    _n(
+                        '%d line item advanced to "In Print" by order-status change.',
+                        '%d line items advanced to "In Print" by order-status change.',
+                        $advanced,
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                    $advanced
+                ));
+            }
+            return;
+        }
+
+        if ($to === 'completed') {
+            $check = $this->is_order_ready_for_completion($order);
+            if ($check['ready']) {
+                return;
+            }
+            self::$inside_self_revert = true;
+            try {
+                $order->update_status(
+                    $from,
+                    __('Cannot complete: product line items still pending print. Resolve item statuses first.', 'studiou-wc-ord-print-statuses')
+                );
+                $order->save();
+            } finally {
+                self::$inside_self_revert = false;
+            }
+            UtilsLog::message(
+                sprintf(
+                    /* translators: 1: order ID, 2: number of blocking items. */
+                    __('Order #%1$d completion blocked — %2$d product items not yet "Done Print" or "Skip Print".', 'studiou-wc-ord-print-statuses'),
+                    $order_id,
+                    count($check['blocking'])
+                ),
+                'error'
+            );
+            UtilsLog::log('Order #' . $order_id . ' completion blocked: ' . count($check['blocking']) . ' item(s) not ready');
+        }
+    }
+}

+ 26 - 50
studiou-wc-ord-print-statuses/includes/class-order-search-manager.php

@@ -1,63 +1,39 @@
 <?php
-// Extends order search functionality.
+/**
+ * Extends WooCommerce order search to find orders by external_ref_ord_no.
+ *
+ * The hooks below cover both legacy (CPT) and HPOS storage modes. They make the
+ * existing "Search orders" input also match the external reference number meta
+ * — no additional UI is required.
+ */
+
+defined('ABSPATH') || exit;
 
 class Order_Search_Manager {
-    public function __construct() {
-        add_filter('manage_woocommerce_page_wc-orders_columns', array($this, 'add_external_ref_ord_no_to_search'));
-        add_action('manage_woocommerce_page_wc-orders_custom_column', array($this, 'add_external_ref_ord_no_search_field'));
-        add_filter('parse_query', array($this, 'process_external_ref_ord_no_search')); // Uncommented this line
-    }
+    const META_KEY = 'external_ref_ord_no';
 
-    /**
-     * Adds the external_ref_ord_no to the list of searchable fields for orders.
-     *
-     * @param array $search_fields The current array of searchable fields.
-     * @return array The updated array of searchable fields.
-     */
-    public function add_external_ref_ord_no_to_search($search_fields) {
-        $search_fields[] = '_external_ref_ord_no';
-        return $search_fields;
-    }
+    public function __construct() {
+        // Legacy CPT-backed storage: WC includes these post-meta keys in the search query.
+        add_filter('woocommerce_shop_order_search_fields', array($this, 'add_meta_key_to_search'));
 
-    /**
-     * Adds a search field for external_ref_ord_no in the order list page.
-     */
-    public function add_external_ref_ord_no_search_field() {
-        global $typenow;
-        if ('shop_order' === $typenow) {
-            $external_ref_ord_no = isset($_GET['external_ref_ord_no']) ? sanitize_text_field($_GET['external_ref_ord_no']) : '';
-            ?>
-            <input type="text" name="external_ref_ord_no" value="<?php echo esc_attr($external_ref_ord_no); ?>" placeholder="<?php esc_attr_e('Search by External Order Number', 'studiou-wc-ord-print-statuses'); ?>">
-            <?php
-        }
+        // HPOS-backed storage: WC searches order meta listed here.
+        add_filter('woocommerce_order_table_search_query_meta_keys', array($this, 'add_meta_key_to_search'));
     }
 
     /**
-     * Processes the external_ref_ord_no search query.
+     * Append the external reference meta key to whichever list WC passes us.
+     * Same callback works for both filters above.
      *
-     * @param WP_Query $query The current WordPress query object.
-     * @return WP_Query The modified query object.
+     * @param array $keys Meta keys / search fields collected by WC.
+     * @return array
      */
-    public function process_external_ref_ord_no_search($query) {
-        global $pagenow, $typenow;
-
-        if ('edit.php' !== $pagenow || 'shop_order' !== $typenow || !$query->is_main_query()) {
-            return $query;
+    public function add_meta_key_to_search($keys) {
+        if (!is_array($keys)) {
+            $keys = array();
         }
-
-        if (!empty($_GET['external_ref_ord_no'])) {
-            $external_ref_ord_no = sanitize_text_field($_GET['external_ref_ord_no']);
-            $meta_query = $query->get('meta_query') ? $query->get('meta_query') : array();
-
-            $meta_query[] = array(
-                'key' => '_external_ref_ord_no',
-                'value' => $external_ref_ord_no,
-                'compare' => 'LIKE',
-            );
-
-            $query->set('meta_query', $meta_query);
+        if (!in_array(self::META_KEY, $keys, true)) {
+            $keys[] = self::META_KEY;
         }
-
-        return $query;
+        return $keys;
     }
-}
+}

+ 115 - 79
studiou-wc-ord-print-statuses/includes/class-order-status-manager.php

@@ -1,79 +1,115 @@
-<?php
-// Handles custom order status registration and integration.
-class Order_Status_Manager {
-    private $custom_statuses;
-
-    public function __construct() {
-        $this->custom_statuses = array(
-            'wc-to-print' => array(
-                'label' => _x('Prepare to Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
-                'label_count' => _n_noop('Prepare to Printing <span class="count">(%s)</span>', 'Prepare to Printing <span class="count">(%s)</span>', 'studiou-wc-ord-print-statuses')
-            ),
-            'wc-in-print' => array(
-                'label' => _x('In Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
-                'label_count' => _n_noop('In Printing <span class="count">(%s)</span>', 'In Printing <span class="count">(%s)</span>', 'studiou-wc-ord-print-statuses')
-            )
-        );
-
-        add_action('init', array($this, 'register_custom_order_statuses'));
-        add_filter('wc_order_statuses', array($this, 'add_custom_order_statuses_to_list'));
-        add_action('woocommerce_order_status_changed', array($this, 'handle_status_transitions'), 10, 3);
-        add_action('woocommerce_payment_complete', array($this, 'set_order_processing_status'));
-
-    }
-
-    public function register_custom_order_statuses() {
-        foreach ($this->custom_statuses as $status_slug => $status_args) {
-            register_post_status($status_slug, array(
-                'label' => $status_args['label'],
-                'public' => true,
-                'exclude_from_search' => false,
-                'show_in_admin_all_list' => true,
-                'show_in_admin_status_list' => true,
-                'label_count' => $status_args['label_count']
-            ));
-        }
-        UtilsLog::log('Statuses registered');
-    }
-
-    public function add_custom_order_statuses_to_list($order_statuses) {
-        $new_order_statuses = array();
-
-        foreach ($order_statuses as $key => $status) {
-            $new_order_statuses[$key] = $status;
-
-            if ($key === 'wc-processing') {
-                foreach ($this->custom_statuses as $status_slug => $status_args) {
-                    $new_order_statuses[$status_slug] = $status_args['label'];
-                }
-            }        }
-
-        return $new_order_statuses;
-    }
-
-    public function handle_status_transitions($order_id, $old_status, $new_status) {
-        // Handle status transitions here
-        // For example:
-        UtilsLog::log('Order #' . $order_id . ' status change ' . $old_status . ' -> ' . $new_status);
-        if ($new_status === 'to-print') {
-            update_post_meta($order_id, 'to_print_date', current_time('mysql'));
-        } elseif ($new_status === 'in-print') {
-            update_post_meta($order_id, 'in_print_date', current_time('mysql'));
-        }
-    }
-
-    public function set_order_processing_status($order_id) {
-        UtilsLog::log('Order #' . $order_id . ' payment complete');
-        $order = wc_get_order($order_id);
-        if ($order && !$order->has_status('completed')) {
-            UtilsLog::log('Order #' . $order_id . ' will be move to processing');
-            $order->update_status('processing', __('Payment received, order is now processing.', 'studiou-wc-ord-print-statuses'));
-            // Add a note to the order
-            $order->add_order_note(__('Order automatically set to processing by Studiou WC Order Print Statuses plugin.', 'studiou-wc-ord-print-statuses'));
-            
-        }
-    }
-    public function get_custom_statuses() {
-        return $this->custom_statuses;
-    }
-}
+<?php
+// Handles custom order status registration and integration.
+
+defined('ABSPATH') || exit;
+
+class Order_Status_Manager {
+    private $custom_statuses;
+    private $print_status_slugs = array('to-print', 'in-print');
+
+    public function __construct() {
+        add_action('init', array($this, 'register_custom_order_statuses'));
+        add_filter('wc_order_statuses', array($this, 'add_custom_order_statuses_to_list'));
+        add_action('woocommerce_order_status_changed', array($this, 'handle_status_transitions'), 10, 3);
+
+        // Earlier versions of this plugin hooked `woocommerce_payment_complete`
+        // to force orders to `processing`. That handler was effectively dead
+        // code on modern WC — WC_Order::payment_complete() already sets the
+        // status to `processing` (or `completed` for downloadable-only orders)
+        // *before* firing the action, so our handler was either a no-op
+        // (same-state update) or a deliberate skip. Removed in 1.4.3.
+    }
+
+    private function get_custom_status_definitions() {
+        if ($this->custom_statuses === null) {
+            $this->custom_statuses = array(
+                'wc-to-print' => array(
+                    'label'       => _x('Prepare to Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
+                    'label_count' => _n_noop(
+                        'Prepare to Printing <span class="count">(%s)</span>',
+                        'Prepare to Printing <span class="count">(%s)</span>',
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                ),
+                'wc-in-print' => array(
+                    'label'       => _x('In Printing', 'Order status', 'studiou-wc-ord-print-statuses'),
+                    'label_count' => _n_noop(
+                        'In Printing <span class="count">(%s)</span>',
+                        'In Printing <span class="count">(%s)</span>',
+                        'studiou-wc-ord-print-statuses'
+                    ),
+                ),
+            );
+        }
+        return $this->custom_statuses;
+    }
+
+    public function register_custom_order_statuses() {
+        foreach ($this->get_custom_status_definitions() as $status_slug => $status_args) {
+            register_post_status($status_slug, array(
+                'label'                     => $status_args['label'],
+                'public'                    => true,
+                'exclude_from_search'       => false,
+                'show_in_admin_all_list'    => true,
+                'show_in_admin_status_list' => true,
+                'label_count'               => $status_args['label_count'],
+            ));
+        }
+    }
+
+    public function add_custom_order_statuses_to_list($order_statuses) {
+        $new_order_statuses = array();
+        $inserted           = false;
+        foreach ($order_statuses as $key => $status) {
+            $new_order_statuses[$key] = $status;
+            if ($key === 'wc-processing') {
+                foreach ($this->get_custom_status_definitions() as $status_slug => $status_args) {
+                    $new_order_statuses[$status_slug] = $status_args['label'];
+                }
+                $inserted = true;
+            }
+        }
+        // Fallback: append at the end if `wc-processing` was removed by
+        // another plugin. Better to surface the custom statuses somewhere
+        // in the dropdown than to drop them silently.
+        if (!$inserted) {
+            foreach ($this->get_custom_status_definitions() as $status_slug => $status_args) {
+                $new_order_statuses[$status_slug] = $status_args['label'];
+            }
+        }
+        return $new_order_statuses;
+    }
+
+    /**
+     * Stamp the relevant meta key when an order transitions into a print status.
+     * Uses the order object (HPOS-safe) rather than update_post_meta.
+     *
+     * Note: bulk and import paths in Studiou_DB_Manager also stamp the same
+     * meta key explicitly. That redundancy is intentional — the bulk path
+     * always re-stamps (a re-run should refresh the timestamp), while this
+     * listener handles manual single-order transitions originating from the
+     * order edit screen. The "only if empty" guard below preserves the
+     * original timestamp for manual flows; the bulk path overwrites it via
+     * its own explicit call.
+     */
+    public function handle_status_transitions($order_id, $old_status, $new_status) {
+        UtilsLog::log('Order #' . $order_id . ' status change ' . $old_status . ' -> ' . $new_status);
+
+        if (!in_array($new_status, $this->print_status_slugs, true)) {
+            return;
+        }
+        $order = wc_get_order($order_id);
+        if (!$order) {
+            return;
+        }
+        $meta_key = ($new_status === 'to-print') ? 'to_print_date' : 'in_print_date';
+        if (!$order->get_meta($meta_key)) {
+            $order->update_meta_data($meta_key, current_time('mysql'));
+            $order->save();
+        }
+    }
+
+    public function get_custom_statuses() {
+        return $this->get_custom_status_definitions();
+    }
+}

+ 78 - 28
studiou-wc-ord-print-statuses/includes/utils-log.php

@@ -1,28 +1,78 @@
-<?php
-//  Basic logging functionality to std log
-
-
-class UtilsLog {
-    public static function log($message) {
-        if ( true === WP_DEBUG ) {
-            if ( is_array( $message ) || is_object( $message ) ) {
-                error_log( print_r( $message, true ) );
-            } else {
-                error_log( $message );
-            }
-        }
-    }
-    public static function message($message) {
-
-        /*
-        wp_admin_notice(
-            __( $message, 'studiou-wc-ord-print-statuses' ),
-            array(       
-                'type'             => 'info',         
-                'additional_classes' => array( 'updated' ),
-                'dismissible'        => true
-            )
-        );      
-          */
-    }
-}
+<?php
+// Logging + admin-notice utility for the plugin.
+
+defined('ABSPATH') || exit;
+
+class UtilsLog {
+    const NOTICE_TRANSIENT_PREFIX = 'studiou_wc_ops_notices_';
+    const NOTICE_TTL              = 300; // 5 minutes — survives slow redirects.
+
+    public static function init() {
+        add_action('admin_notices', array(__CLASS__, 'render_notices'));
+    }
+
+    public static function log($message) {
+        if (defined('WP_DEBUG') && WP_DEBUG) {
+            if (is_array($message) || is_object($message)) {
+                error_log('[studiou-wc-ord-print-statuses] ' . print_r($message, true));
+            } else {
+                error_log('[studiou-wc-ord-print-statuses] ' . $message);
+            }
+        }
+    }
+
+    /**
+     * Queue an admin notice for the current user.
+     * Notices survive the redirect that follows bulk actions and imports.
+     *
+     * @param string $message Plain text. Tags are HTML-escaped at render
+     *                        time (esc_html). Callers should pre-format
+     *                        any user-controlled content with esc_html
+     *                        themselves if they need to keep markup such
+     *                        as <code>.
+     * @param string $type    One of: info, success, warning, error.
+     */
+    public static function message($message, $type = 'info') {
+        $user_id = get_current_user_id();
+        if (!$user_id) {
+            return;
+        }
+        $allowed = array('info', 'success', 'warning', 'error');
+        if (!in_array($type, $allowed, true)) {
+            $type = 'info';
+        }
+        $key     = self::NOTICE_TRANSIENT_PREFIX . $user_id;
+        $notices = get_transient($key);
+        if (!is_array($notices)) {
+            $notices = array();
+        }
+        $notices[] = array('message' => (string) $message, 'type' => $type);
+        set_transient($key, $notices, self::NOTICE_TTL);
+    }
+
+    public static function render_notices() {
+        $user_id = get_current_user_id();
+        if (!$user_id) {
+            return;
+        }
+        $key     = self::NOTICE_TRANSIENT_PREFIX . $user_id;
+        $notices = get_transient($key);
+        if (!is_array($notices) || empty($notices)) {
+            return;
+        }
+        delete_transient($key);
+        foreach ($notices as $notice) {
+            $type    = isset($notice['type']) ? $notice['type'] : 'info';
+            $message = isset($notice['message']) ? $notice['message'] : '';
+            // Tight escape — notice messages are plain text in every
+            // current call site. Switching to esc_html (was wp_kses_post)
+            // narrows the trust boundary at the API edge: callers can no
+            // longer accidentally smuggle HTML through the notice queue.
+            printf(
+                '<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
+                esc_attr($type),
+                esc_html($message)
+            );
+        }
+    }
+}

+ 250 - 82
studiou-wc-ord-print-statuses/languages/studiou-wc-ord-print-statuses-cs_CZ.po

@@ -1,82 +1,250 @@
-# Copyright (C) 2024 Dalibor Votruba
-# This file is distributed under the GPL2 license.
-msgid ""
-msgstr ""
-"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.0\n"
-"Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses\n"
-"POT-Creation-Date: 2024-10-05 18:41:15+00:00\n"
-"PO-Revision-Date: 2024-10-05 18:41:15+00:00\n"
-"Last-Translator: \n"
-"Language-Team: \n"
-"Language: cs_CZ\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
-"X-Generator: Poedit 3.0\n"
-"X-Poedit-KeywordsList: __;_e;_n:1,2;_x:1,2c;_ex:1,2c;_nx:4c,1,2;esc_attr__;"
-"esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c;_n_noop:1,2;"
-"_nx_noop:3c,1,2;__ngettext_noop:1,2\n"
-"X-Poedit-Basepath: ..\n"
-"X-Poedit-SearchPath-0: .\n"
-"X-Poedit-SearchPathExcluded-0: node_modules\n"
-
-msgid "Prepare to Printing Export"
-msgstr "Export do přípravy tisku"
-
-msgid "Set Status to Prepare to Printing"
-msgstr "Nastav stav na Příprava Tisku"
-
-msgid "Set Status to In Printing"
-msgstr "Nastav stav na V Tisku"
-
-msgid "To Print Date"
-msgstr "Datum Příprava Tisku"
-
-msgid "In Print Date"
-msgstr "Datum V Tisku"
-
-msgid "External Order Number"
-msgstr "Č.Obj. Tiskárny"
-
-msgid "Order %s not found."
-msgstr "Objednávka č. %s nenalezena"
-
-msgid "Import Protocols"
-msgstr "Import Protokolů"
-
-msgid "Import InPrint Protocol"
-msgstr "Import protokolu V Tisku"
-
-msgid "Import Delivered Protocol"
-msgstr "Import protokolu Tisk Dodán"
-
-msgid "%d orders updated successfully."
-msgstr "%d objednávek úspěšně aktualizováno."
-
-msgid "%d errors occurred: %s"
-msgstr "%d chyb: %s"
-
-msgid "No file uploaded."
-msgstr "Žádný soubor nenahrán."
-
-msgid "Invalid row format in CSV."
-msgstr "Vadný CSV formát."
-
-msgid "Print Information"
-msgstr "Informace Tiskové služby"
-
-msgid "Prepare To Print Date"
-msgstr "Datum stavu Příprava Tisku"
-
-msgid "External Order Delivered"
-msgstr "Datum Dodání Tisku"
-
-msgid "Prepare to Printing"
-msgstr "Příprava Tisku"
-
-msgid "In Printing"
-msgstr "V Tisku"
-
-msgid "Studiou WC Order Print Statuses requires WooCommerce to be installed and active."
-msgstr "StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce."
+# Copyright (C) 2026 Dalibor Votruba
+# This file is distributed under the GPL2 license.
+msgid ""
+msgstr ""
+"Project-Id-Version: QDR - Studiou WC Order Print Statuses 1.5.0\n"
+"Report-Msgid-Bugs-To: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses\n"
+"POT-Creation-Date: 2026-05-12 00:00:00+00:00\n"
+"PO-Revision-Date: 2026-05-12 00:00:00+00:00\n"
+"Last-Translator: Dalibor Votruba <dalibor.votruba@gmail.com>\n"
+"Language-Team: \n"
+"Language: cs_CZ\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
+"X-Generator: Poedit 3.0\n"
+"X-Poedit-KeywordsList: __;_e;_n:1,2;_x:1,2c;_ex:1,2c;_nx:4c,1,2;esc_attr__;"
+"esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c;_n_noop:1,2;"
+"_nx_noop:3c,1,2;__ngettext_noop:1,2\n"
+"X-Poedit-Basepath: ..\n"
+"X-Poedit-SearchPath-0: .\n"
+"X-Poedit-SearchPathExcluded-0: node_modules\n"
+
+# --- Bulk actions ---
+
+msgid "Prepare to Printing Export"
+msgstr "Export do přípravy tisku"
+
+msgid "Set Status to Prepare to Printing"
+msgstr "Nastav stav na Příprava Tisku"
+
+msgid "Set Status to In Printing"
+msgstr "Nastav stav na V Tisku"
+
+# --- Order list columns ---
+
+msgid "To Print Date"
+msgstr "Datum Příprava Tisku"
+
+msgid "In Print Date"
+msgstr "Datum V Tisku"
+
+msgid "External Order Number"
+msgstr "Č.Obj. Tiskárny"
+
+# --- Print Information panel (order edit screen) ---
+
+msgid "Print Information"
+msgstr "Informace Tiskové služby"
+
+msgid "Prepare To Print Date"
+msgstr "Datum stavu Příprava Tisku"
+
+msgid "External Order Date"
+msgstr "Datum Obj. Tiskárny"
+
+msgid "External Order Delivered"
+msgstr "Datum Dodání Tisku"
+
+# --- Custom order statuses (msgctxt "Order status") ---
+
+msgctxt "Order status"
+msgid "Prepare to Printing"
+msgstr "Příprava Tisku"
+
+msgctxt "Order status"
+msgid "In Printing"
+msgstr "V Tisku"
+
+# --- Status count labels (_n_noop) — same singular and plural in source ---
+
+msgid "Prepare to Printing <span class=\"count\">(%s)</span>"
+msgid_plural "Prepare to Printing <span class=\"count\">(%s)</span>"
+msgstr[0] "Příprava Tisku <span class=\"count\">(%s)</span>"
+msgstr[1] "Příprava Tisku <span class=\"count\">(%s)</span>"
+msgstr[2] "Příprava Tisku <span class=\"count\">(%s)</span>"
+
+msgid "In Printing <span class=\"count\">(%s)</span>"
+msgid_plural "In Printing <span class=\"count\">(%s)</span>"
+msgstr[0] "V Tisku <span class=\"count\">(%s)</span>"
+msgstr[1] "V Tisku <span class=\"count\">(%s)</span>"
+msgstr[2] "V Tisku <span class=\"count\">(%s)</span>"
+
+# --- Import Protocols admin screen ---
+
+msgid "Import Protocols"
+msgstr "Import Protokolů"
+
+msgid "Import InPrint Protocol"
+msgstr "Import protokolu V Tisku"
+
+msgid "Import Delivered Protocol"
+msgstr "Import protokolu Tisk Dodán"
+
+msgid "Required CSV headers: %s"
+msgstr "Povinné hlavičky CSV: %s"
+
+# --- Import / bulk-action notices (plurals) ---
+
+msgid "%d order updated successfully."
+msgid_plural "%d orders updated successfully."
+msgstr[0] "%d objednávka úspěšně aktualizována."
+msgstr[1] "%d objednávky úspěšně aktualizovány."
+msgstr[2] "%d objednávek úspěšně aktualizováno."
+
+msgid "%d order moved to \"Prepare to Printing\"."
+msgid_plural "%d orders moved to \"Prepare to Printing\"."
+msgstr[0] "%d objednávka přesunuta na \"Příprava Tisku\"."
+msgstr[1] "%d objednávky přesunuty na \"Příprava Tisku\"."
+msgstr[2] "%d objednávek přesunuto na \"Příprava Tisku\"."
+
+msgid "%d order moved to \"In Printing\"."
+msgid_plural "%d orders moved to \"In Printing\"."
+msgstr[0] "%d objednávka přesunuta na \"V Tisku\"."
+msgstr[1] "%d objednávky přesunuty na \"V Tisku\"."
+msgstr[2] "%d objednávek přesunuto na \"V Tisku\"."
+
+msgid "%d order skipped because its status is final (cancelled, refunded, failed, or completed)."
+msgid_plural "%d orders skipped because their status is final (cancelled, refunded, failed, or completed)."
+msgstr[0] "%d objednávka přeskočena, protože je v koncovém stavu (zrušeno, vráceno, selhalo, dokončeno)."
+msgstr[1] "%d objednávky přeskočeny, protože jsou v koncovém stavu (zrušeno, vráceno, selhalo, dokončeno)."
+msgstr[2] "%d objednávek přeskočeno, protože jsou v koncovém stavu (zrušeno, vráceno, selhalo, dokončeno)."
+
+# --- Other formatted notices ---
+
+msgid "%1$d errors occurred: %2$s"
+msgstr "Počet chyb: %1$d — %2$s"
+
+msgid "No exportable line items were found for the selected orders. CSV was not generated."
+msgstr "Pro vybrané objednávky nebyly nalezeny žádné položky k exportu. CSV nebylo vygenerováno."
+
+# --- Error / status messages ---
+
+msgid "Order %s not found."
+msgstr "Objednávka č. %s nenalezena."
+
+msgid "Order %s is in a final state and was not updated."
+msgstr "Objednávka č. %s je v koncovém stavu a nebyla aktualizována."
+
+msgid "Invalid row format in CSV."
+msgstr "Vadný CSV formát."
+
+msgid "No file uploaded."
+msgstr "Žádný soubor nenahrán."
+
+msgid "Uploaded file is not accessible."
+msgstr "Nahraný soubor není dostupný."
+
+msgid "File upload failed (error code %d)."
+msgstr "Nahrání souboru selhalo (chybový kód %d)."
+
+msgid "CSV file could not be parsed or contained no data rows."
+msgstr "CSV soubor se nepodařilo zpracovat nebo neobsahoval žádné datové řádky."
+
+msgid "Only .csv files are accepted."
+msgstr "Přijímány jsou pouze soubory .csv."
+
+msgid "You do not have sufficient permissions to access this page."
+msgstr "Nemáte dostatečná oprávnění k zobrazení této stránky."
+
+msgid "You do not have sufficient permissions to perform this action."
+msgstr "Nemáte dostatečná oprávnění k provedení této akce."
+
+# --- Activation guard ---
+
+msgid "Studiou WC Order Print Statuses requires WooCommerce to be installed and active."
+msgstr "StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce."
+
+# --- CSV export safety guards ---
+
+msgid "The CSV export could not be streamed because output had already started. Check for plugin/theme code that prints before headers are sent."
+msgstr "CSV export nebylo možné odeslat, protože výstup již začal. Zkontrolujte kód pluginů/šablon, který tiskne před odesláním HTTP hlaviček."
+
+msgid "Export requires the WooCommerce analytics lookup table (wc_order_product_lookup). Enable WooCommerce Analytics or contact support."
+msgstr "Export vyžaduje tabulku WooCommerce analytics (wc_order_product_lookup). Aktivujte WooCommerce Analytics nebo kontaktujte podporu."
+
+msgid "Export requires WooCommerce HPOS (custom order tables). Enable High-Performance Order Storage in WooCommerce → Settings → Advanced → Features, or contact support."
+msgstr "Export vyžaduje WooCommerce HPOS (vlastní tabulky objednávek). Aktivujte High-Performance Order Storage v WooCommerce → Nastavení → Pokročilé → Funkce, nebo kontaktujte podporu."
+
+# --- Order notes passed to update_status from import paths (new in 1.4.3) ---
+
+msgid "Marked In Printing by InPrint Protocol import."
+msgstr "Označeno jako \"V Tisku\" importem protokolu V Tisku."
+
+msgid "Marked completed by Delivered Protocol import."
+msgstr "Označeno jako \"Dokončeno\" importem protokolu Tisk Dodán."
+
+# --- Order notes passed to update_status from bulk-action paths (new in 1.4.4) ---
+
+msgid "Marked Prepare to Printing by bulk action."
+msgstr "Označeno jako \"Příprava Tisku\" hromadnou akcí."
+
+msgid "Marked In Printing by bulk action."
+msgstr "Označeno jako \"V Tisku\" hromadnou akcí."
+
+# --- Import-summary info notice (new in 1.4.5) ---
+
+msgid "The CSV file contained no actionable rows (empty order_no column)."
+msgstr "CSV soubor neobsahoval žádné použitelné řádky (sloupec order_no je prázdný)."
+
+# --- Per-order-item print status feature (new in 1.5.0) ---
+
+# Item status labels
+msgid "Pending Print"
+msgstr "Čeká na Tisk"
+
+msgid "In Print"
+msgstr "V Tisku"
+
+msgid "Done Print"
+msgstr "Vytištěno"
+
+msgid "Skip Print"
+msgstr "Přeskočit Tisk"
+
+# Items-table column header (introduced by the same column header symbol used by Order_Item_Status_Manager — distinct from "External Order Number" / column titles above)
+msgid "Print Status"
+msgstr "Stav Tisku"
+
+# Per-item audit note
+msgid "Item #%1$d (%2$s): %3$s → %4$s."
+msgstr "Položka č. %1$d (%2$s): %3$s → %4$s."
+
+# Propagation summary (order → in-print)
+msgid "%d line item advanced to \"In Print\" by order-status change."
+msgid_plural "%d line items advanced to \"In Print\" by order-status change."
+msgstr[0] "%d položka přesunuta na \"V Tisku\" kvůli změně stavu objednávky."
+msgstr[1] "%d položky přesunuty na \"V Tisku\" kvůli změně stavu objednávky."
+msgstr[2] "%d položek přesunuto na \"V Tisku\" kvůli změně stavu objednávky."
+
+# Delivered Protocol per-order advance note
+msgid "%d line item advanced to \"Done Print\" by Delivered Protocol import."
+msgid_plural "%d line items advanced to \"Done Print\" by Delivered Protocol import."
+msgstr[0] "%d položka přesunuta na \"Vytištěno\" importem protokolu Tisk Dodán."
+msgstr[1] "%d položky přesunuty na \"Vytištěno\" importem protokolu Tisk Dodán."
+msgstr[2] "%d položek přesunuto na \"Vytištěno\" importem protokolu Tisk Dodán."
+
+# Completion guard
+msgid "Cannot complete: product line items still pending print. Resolve item statuses first."
+msgstr "Nelze dokončit: položky objednávky ještě nejsou vytištěné. Nejprve vyřešte stavy položek."
+
+msgid "Order #%1$d completion blocked — %2$d product items not yet \"Done Print\" or \"Skip Print\"."
+msgstr "Dokončení objednávky č. %1$d zablokováno — %2$d položek dosud není ve stavu \"Vytištěno\" nebo \"Přeskočit Tisk\"."
+
+# Delivered Protocol cross-import summary
+msgid "%d line item advanced to \"Done Print\" to satisfy completion guard."
+msgid_plural "%d line items advanced to \"Done Print\" to satisfy completion guard."
+msgstr[0] "%d položka přesunuta na \"Vytištěno\" pro splnění podmínky dokončení."
+msgstr[1] "%d položky přesunuty na \"Vytištěno\" pro splnění podmínky dokončení."
+msgstr[2] "%d položek přesunuto na \"Vytištěno\" pro splnění podmínky dokončení."

+ 27 - 25
studiou-wc-ord-print-statuses/studiou-wc-ord-print-statuses.php

@@ -3,12 +3,12 @@
  * Plugin Name: QDR - Studiou WC Order Print Statuses
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-ord-print-statuses
  * Description: Adds custom order statuses (wc-to-print, wc-in-print), bulk actions, and status change timestamps to WooCommerce for print orders to third party providers
- * Version: 1.3.2
+ * Version: 1.5.0
  * Requires at least: 5.8
- * Tested up to: 6.8.3
+ * Tested up to: 6.9.4
  * Requires PHP: 7.2
  * WC requires at least: 3.0
- * WC tested up to: 10.2.2
+ * WC tested up to: 10.7.0
  * Author: Dalibor Votruba
  * Author URI: https://www.quadarax.com
  * License: GPL v2 or later
@@ -19,12 +19,18 @@
 
 defined('ABSPATH') || exit;
 
-// Declare HPOS (High-Performance Order Storage) compatibility
-add_action('before_woocommerce_init', function() {
+define('STUDIOU_WC_OPS_VERSION', '1.5.0');
+define('STUDIOU_WC_OPS_FILE', __FILE__);
+
+// Declare HPOS (High-Performance Order Storage) compatibility.
+// Named function so other plugins or test harnesses can remove_action it
+// if they need to override the declaration.
+function studiou_wc_ord_print_statuses_declare_hpos_compat() {
     if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
-        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
+        \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', STUDIOU_WC_OPS_FILE, true);
     }
-});
+}
+add_action('before_woocommerce_init', 'studiou_wc_ord_print_statuses_declare_hpos_compat');
 
 // load logging feature
 require_once plugin_dir_path(__FILE__) . 'includes/utils-log.php';
@@ -37,6 +43,7 @@ class Studiou_WC_Ord_Print_Statuses {
     private $bulk_actions_manager;
     private $custom_columns_manager;
     private $import_manager;
+    private $order_item_status_manager;
 
     public static function initPlugin() {
         $class = __CLASS__;
@@ -44,7 +51,6 @@ class Studiou_WC_Ord_Print_Statuses {
     }
 
     public function __construct() {
-        // add_action('plugins_loaded', array($this, 'init'));
         $this->init();
     }
 
@@ -56,7 +62,7 @@ class Studiou_WC_Ord_Print_Statuses {
 
         $this->load_dependencies();
         $this->initialize_managers();
-        UtilsLog::log("Plugin initialized");
+        UtilsLog::init();
     }
 
     private function load_dependencies() {
@@ -67,25 +73,23 @@ class Studiou_WC_Ord_Print_Statuses {
             'class-order-search-manager.php',
             'class-bulk-actions-manager.php',
             'class-custom-columns-manager.php',
-            'class-import-manager.php'
+            'class-import-manager.php',
+            'class-order-item-status-manager.php'
         );
 
         foreach ($files as $file) {
             require_once plugin_dir_path(__FILE__) . 'includes/' . $file;
         }
-
-        UtilsLog::log('Dependencies loaded');
     }
 
     private function initialize_managers() {
-        $this->order_status_manager = new Order_Status_Manager();
-        $this->order_fields_manager = new Order_Fields_Manager();
-        $this->order_search_manager = new Order_Search_Manager();
-        $this->bulk_actions_manager = new Bulk_Actions_Manager();
-        $this->custom_columns_manager = new Custom_Columns_Manager();
-        $this->import_manager = new Import_Manager();
-
-        UtilsLog::log('Managers initialized');
+        $this->order_status_manager      = new Order_Status_Manager();
+        $this->order_fields_manager      = new Order_Fields_Manager();
+        $this->order_search_manager      = new Order_Search_Manager();
+        $this->bulk_actions_manager      = new Bulk_Actions_Manager();
+        $this->custom_columns_manager    = new Custom_Columns_Manager();
+        $this->import_manager            = new Import_Manager();
+        $this->order_item_status_manager = new Order_Item_Status_Manager();
     }
 
     public function woocommerce_missing_notice() {
@@ -97,11 +101,9 @@ class Studiou_WC_Ord_Print_Statuses {
     }
 }
 
-// Load the plugin text domain - translations
+// Load translations first (priority 5), then boot the plugin (default priority 10).
 function studiou_wc_ord_print_statuses_load_textdomain() {
     load_plugin_textdomain('studiou-wc-ord-print-statuses', false, dirname(plugin_basename(__FILE__)) . '/languages');
-    UtilsLog::log("Text domain (translations) loaded");
 }
-add_action('plugins_loaded', 'studiou_wc_ord_print_statuses_load_textdomain');
-// Initialize the plugin
-add_action( 'plugins_loaded', array( 'Studiou_WC_Ord_Print_Statuses', 'initPlugin' ));
+add_action('plugins_loaded', 'studiou_wc_ord_print_statuses_load_textdomain', 5);
+add_action('plugins_loaded', array('Studiou_WC_Ord_Print_Statuses', 'initPlugin'));

+ 0 - 25
studiou-wc-ord-print-statuses/translations.txt

@@ -1,25 +0,0 @@
-Tranlations all text from English (ENG) to Czech (CZ).
-English																						Česky
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
-Prepare to Printing Export																	Export do přípravy tisku
-Set Status to Prepare to Printing															Nastav stav na Příprava Tisku
-Set Status to In Printing																	Nastav stav na V Tisku
-To Print Date																				Datum Příprava Tisku
-In Print Date																				Datum V Tisku
-External Order Number																		Č.Obj. Tiskárny
-Order %s not found.																			Objednávka č. %s nenalezena
-Import Protocols																			Import Protokolů
-Import InPrint Protocol																		Import protokolu V Tisku
-Import Delivered Protocol																	Import protokolu Tisk Dodán
-%d orders updated successfully.																%d objednávek úspěšně aktualizováno.
-%d errors occurred: %s																		%d chyb: %s
-No file uploaded.																			Žádný soubor nenahrán.
-Invalid row format in CSV.																	Vadný CSV formát.
-Order %s not found.																			Objednávka č. %s nenalezena.
-Print Information																			Informace Tiskové služby
-Prepare To Print Date																		Datum stavu Příprava Tisku
-External Order Number																		Č.Obj. Tiskárny
-External Order Delivered																	Datum Dodání Tisku
-Prepare to Printing																			Příprava Tisku
-In Printing																					V Tisku
-Studiou WC Order Print Statuses requires WooCommerce to be installed and active.			StudioU WC Order Print Statuses vyžaduje mít nainstalovaný a aktivní plugin WooCommerce.