Forráskód Böngészése

Merge branch '1.0.0.studiou-wc-free-photo-product'

Dalibor Votruba 2 hónapja
szülő
commit
1efef13658
23 módosított fájl, 3892 hozzáadás és 438 törlés
  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

+ 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>