소스 검색

Add photo lightbox + fix block-cart thumbnail mismatch — v1.5.3 -> v1.5.8

- New "Enlarge" button in each upload card (price-row) and overview line opens
  a lightbox with the woocommerce_single-size preview. Wired via direct
  element.onclick to survive theme-level click interceptors; falls back to
  window.open() if the overlay DOM fails to initialize.
- Replaced the broken woocommerce_product_get_image_id override with a
  woocommerce_get_cart_item_from_session filter that clones each cart line's
  product instance and set_image_id()s the uploaded attachment onto it. Cart
  lines sharing a variation_id now each render their own photo in block cart.
- Hide the per-card progress overlay on restored (non-uploading) cards.
- New i18n strings: Close, Enlarge (translated to Zavřít, Zvětšit in cs_CZ).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 달 전
부모
커밋
06faf7bf00

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

@@ -9,7 +9,16 @@
       "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//**)"
+      "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)"
     ]
   }
 }

+ 4 - 3
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.5.3.
+WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.5.8.
 Allows publishing special variable products where customers upload custom raw images to be printed for a price.
 
 ## Initial Requirements
@@ -41,7 +41,7 @@ Allows publishing special variable products where customers upload custom raw im
 - `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 via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`), order item meta, order linking. `render_overview_html($product_id)` returns the panel HTML for in-place re-render after cart changes.
+- `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.
 
@@ -71,7 +71,8 @@ Allows publishing special variable products where customers upload custom raw im
 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 via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`.
+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`).

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

@@ -498,6 +498,18 @@
     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%;
@@ -542,6 +554,41 @@
     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;
@@ -666,6 +713,12 @@
     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 {
@@ -802,3 +855,73 @@
         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);
+}

+ 191 - 4
studiou-wc-free-photo-product/assets/js/frontend.js

@@ -35,6 +35,8 @@
         initDropzone();
         initCardDelegation();
         initOverviewDelegation();
+        initLightbox();
+        initOverviewThumbClicks();
         applyHeroImage(data.heroPreviewUrl);
 
         // ==========================================
@@ -47,6 +49,7 @@
                     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
                 });
@@ -69,13 +72,23 @@
             // Thumb column
             var $thumb = $('<div class="studiou-fpp-card-thumb"></div>');
             if (opts.thumbUrl) {
-                $thumb.append($('<img>').attr('src', opts.thumbUrl).attr('alt', opts.fileName || ''));
+                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);
 
@@ -91,6 +104,10 @@
             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);
@@ -365,12 +382,15 @@
             $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);
+                $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)
+                    $('<img>').attr('src', res.thumbnail_url)
+                              .attr('alt', res.file_name)
+                              .attr('data-preview-url', previewUrl)
                 );
             }
             updateCardPrice($card);
@@ -378,7 +398,7 @@
 
             // If this is now the first/only card, swap the hero gallery image
             if ($cards.children('.studiou-fpp-card').index($card) === 0) {
-                applyHeroImage(res.preview_url || res.thumbnail_url);
+                applyHeroImage(previewUrl);
             }
         }
 
@@ -533,6 +553,7 @@
             } else {
                 $wrap.after($new);
             }
+            initOverviewThumbClicks();
             $(document).trigger('studiou-fpp:cart-updated');
         }
 
@@ -568,6 +589,172 @@
             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);
+            });
+
+            // 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 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 = [

+ 25 - 28
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-cart.php

@@ -29,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);
@@ -233,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) {

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

@@ -111,6 +111,7 @@ class Studiou_WC_FPP_Single_Product {
                 '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,
             );
         }

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


+ 7 - 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.5.3\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.5.8\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"
@@ -85,6 +85,12 @@ 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 "Media Categories"
 msgstr "Kategorie médií"
 

+ 9 - 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.5.3\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.5.8\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"
@@ -106,6 +106,14 @@ 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 ""
+
 #: includes/class-studiou-wc-fpp-db.php
 msgid "Media Categories"
 msgstr ""

+ 7 - 3
studiou-wc-free-photo-product/readme.md

@@ -2,6 +2,11 @@
 
 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.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).
@@ -140,7 +145,7 @@ Navigate to **Products > Free Photo Files** to manage uploaded files.
 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
@@ -259,8 +264,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) |

+ 4 - 2
studiou-wc-free-photo-product/studiou-wc-free-photo-product.php

@@ -3,7 +3,7 @@
  * 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.5.3
+ * Version: 1.5.8
  * Requires at least: 6.9.4
  * Requires PHP: 8.2
  * Requires Plugins: woocommerce, product-variant-table-for-woocommerce
@@ -22,7 +22,7 @@ if (!defined('WPINC')) {
     die;
 }
 
-if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.5.3');
+if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.5.8');
 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__));
@@ -269,6 +269,8 @@ 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'),

+ 11 - 2
studiou-wc-free-photo-product/views/single-product-order-overview.php

@@ -38,6 +38,12 @@ if ($cart && $cart instanceof WC_Cart) {
                 $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);
@@ -66,10 +72,13 @@ if ($cart && $cart instanceof WC_Cart) {
                 ?>
                 <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="" />
+                        <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"><?php echo esc_html($name); ?></div>
+                        <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>