Explorar o código

1.5.0 phase 2+3: upload cards stack + per-card add-to-cart

Replaces pvtfw's variant table with a vertical stack of per-upload cards.
Each card is self-contained: thumbnail + filename + variant selector + qty
stepper + live tier-discounted price + Add to Cart + Remove Upload (×).

Changes
-------
- Single_Product::maybe_suppress_pvtfw() on `wp` priority 5 detaches pvtfw's
  print_table hook on FPP products. The hook name + priority are read from
  pvtfw_variant_table_place (pvtfw's own option, default
  woocommerce_after_single_product_summary_9) so admin-configured placements
  Just Work. Also detaches pvtfw_available_btn at summary:11. body class
  studiou-fpp-product-page added for CSS scoping.
- Single_Product::render_upload_area() rewritten: passes product variants,
  existing batch files, max uploads, and hero preview URL to
  studiouFppCardData. Includes views/single-product-upload.php for the
  dropzone + cards shell and views/single-product-order-overview.php for
  the "Your order" panel (filtered to this product's FPP lines only).
- views/single-product-upload.php: minimal shell — dropzone with multi-file
  input, messages area, #studiou-fpp-cards container. All cards rendered
  by JS from studiouFppCardData.
- views/single-product-order-overview.php: NEW. Server-rendered initial
  state showing uploaded-photo thumb, variant, qty, line total,
  discount badge (if tier active), and per-line Remove link. Scoped to
  $product_id via the included template's filter.
- assets/js/frontend.js: full rewrite. Card builder with variant <select>
  + qty stepper + live price/badge/add-to-cart. Multi-file upload queue
  (sequential chunked upload). Add-to-Cart click POSTs to new AJAX
  endpoint with file_record_id + variation_id + qty. Remove-upload
  (× button) calls existing studiou_wcfpp_remove_upload. Remove-line
  on overview panel calls new studiou_wcfpp_remove_cart_line. Hero
  gallery image swapped to first uploaded file (or restored when the
  stack empties). Old single-file gallery + native-form tier paths
  deleted.
- Cart::ajax_add_file_to_cart() + ajax_remove_cart_line(): new AJAX
  handlers. add_cart_item_data() now prefers pre-stamped file_record_id
  from $cart_item_data (the new per-card flow); legacy visitor-level
  resolver kept as fallback for the shortcode path. render_overview_html()
  returns the panel HTML for in-place re-render after cart changes.
- Single_Product::validate_add_to_cart() accepts the new per-line
  file_record_id as the primary "photo present" signal.
- Main plugin: i18n additions (addToCart, maxUploadsReached, outOfStock,
  error).
- CSS: card styles (thumb + progress overlay, controls, price row,
  badge, remove button), overview panel styles, mobile stacked layout
  below 640 px.

Tier discount logic is unchanged on the server side — Pricing::apply_cart_discount
at woocommerce_before_calculate_totals@20 is still authoritative. Client-side
tier wiring moves from initVariantTableTiers (pvtfw rows) to the per-card
updateCardPrice() on variant/qty change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dalibor Votruba hai 2 meses
pai
achega
5563c2e2bb

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

@@ -420,3 +420,385 @@
         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-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-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;
+}
+
+.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;
+    }
+}

+ 510 - 486
studiou-wc-free-photo-product/assets/js/frontend.js

@@ -3,207 +3,539 @@
 
     $(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();
+        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,
+                    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) {
+                $thumb.append($('<img>').attr('src', opts.thumbUrl).attr('alt', opts.fileName || ''));
+            } else {
+                $thumb.append('<div class="studiou-fpp-card-thumb-placeholder"></div>');
+            }
+            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>');
+            $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>');
+            $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 value="0">' + escHtml(i18n.selectVariant || 'Select variant') + '</option>');
+            for (var i = 0; i < variants.length; i++) {
+                var v = variants[i];
+                var priceLabel = v.base_price ? ' — ' + formatPrice(v.base_price) : '';
+                var $opt = $('<option></option>')
+                    .val(v.id)
+                    .text(v.label + priceLabel)
+                    .attr('data-base-price', v.base_price);
+                if (!v.in_stock) {
+                    $opt.prop('disabled', true).text(v.label + priceLabel + ' — ' + (i18n.outOfStock || 'out of stock'));
+                }
+                $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;
+        }
 
-        $dropzone.on('click', function () {
-            if (!isUploading) {
-                $fileInput.trigger('click');
+        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;
+        }
 
-        $fileInput.on('change', function () {
-            if (this.files.length > 0) {
-                handleFile(this.files[0]);
+        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;
             }
-        });
 
-        // Remove file
-        $removeBtn.on('click', function () {
-            removeUploadedFile();
-        });
+            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));
+
+            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 () {
+                if ($dropzone.hasClass('studiou-fpp-dropzone-disabled')) return;
+                $fileInput.trigger('click');
+            });
+            $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 $img = $card.find('.studiou-fpp-card-thumb img');
+            if ($img.length) {
+                $img.attr('src', res.thumbnail_url);
+            } else {
+                $card.find('.studiou-fpp-card-thumb-placeholder').replaceWith(
+                    $('<img>').attr('src', res.thumbnail_url).attr('alt', res.file_name)
+                );
+            }
+            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(res.preview_url || res.thumbnail_url);
+            }
+        }
 
-            uploadNextChunk();
+        function failUpload($card, message) {
+            $card.remove();
+            showMessage(message, 'error');
+            refreshMaxReachedState();
+        }
+
+        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);
+            }
+            $(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 +544,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 +551,20 @@
             }
             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', imageUrl).removeAttr('srcset').removeAttr('sizes');
-            var $link = $img.closest('a');
-            if ($link.length) $link.attr('href', imageUrl);
+            $img.attr('src', url).removeAttr('srcset').removeAttr('sizes');
+            var $a = $img.closest('a');
+            if ($a.length) $a.attr('href', url);
         }
 
-        function restoreProductGallery() {
-            if (!originalGalleryImages.src) return;
-
+        function restoreHeroImage() {
+            if (!originalGallery.src) return;
             var selectors = [
                 '.woocommerce-product-gallery__image img',
                 '.product .wp-post-image',
@@ -244,377 +573,72 @@
                 '.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 showPreview(thumbnailUrl, fileName) {
-            if (thumbnailUrl) {
-                $previewImg.attr('src', thumbnailUrl).show();
-            } else {
-                $previewImg.hide();
-            }
-            $previewName.text(fileName);
-            $preview.show();
-            $dropzone.hide();
-        }
-
-        function removeUploadedFile() {
-            if (!uploadedFileRecordId) return;
-
-            $.ajax({
-                url: studiouWcfppFront.ajaxUrl,
-                type: 'POST',
-                data: {
-                    action: 'studiou_wcfpp_remove_upload',
-                    nonce: studiouWcfppFront.nonce,
-                    file_record_id: uploadedFileRecordId
-                },
-                success: function () {
-                    resetUpload();
-                }
-            });
-        }
-
-        function resetUpload() {
-            uploadedAttachmentId = null;
-            uploadedFileRecordId = null;
-            clearSession();
-            restoreProductGallery();
-            $preview.hide();
-            $previewImg.attr('src', '');
-            $previewName.text('');
-            $dropzone.show();
-            $fileInput.val('');
-            clearMessages();
-        }
-
         function showMessage(text, type) {
-            var cls = type === 'error' ? 'studiou-fpp-msg-error' : 'studiou-fpp-msg-success';
+            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 clearMessages() {
-            $messages.empty();
-        }
+        function clearMessages() { $messages.empty(); }
 
         function escHtml(str) {
             var div = document.createElement('div');
-            div.appendChild(document.createTextNode(str));
+            div.appendChild(document.createTextNode(str == null ? '' : String(str)));
             return div.innerHTML;
         }
 
-        // ==========================================
-        // Quantity discount tier display + live price update
-        // ==========================================
-        initQtyTiers();
-        initVariantTableTiers();
-
-        function initVariantTableTiers() {
-            // Handles custom variant-table plugins (e.g. pvtfw / product-variant-table-for-woocommerce)
-            // where each variation is rendered as a <tr> with its own qty input and price.
-            var tierData = (typeof window.studiouFppTierData === 'object' && window.studiouFppTierData) ? window.studiouFppTierData : null;
-            if (!tierData) {
-                return;
-            }
-            var $table = $('table.variant').first();
-            if (!$table.length) {
-                return;
-            }
-
-            var i18n = studiouWcfppFront.i18n;
-
-            $table.find('tbody > tr').each(function () {
-                var $row = $(this);
-                var $cartBtn = $row.find('.pvtfw_variant_table_cart_btn').first();
-                var variantId = $cartBtn.length ? String($cartBtn.data('variant')) : '';
-                if (!variantId) {
-                    var $qtyInput = $row.find('input.qty').first();
-                    variantId = $qtyInput.attr('id') || '';
-                }
-                if (!variantId || !tierData[variantId]) {
-                    return;
-                }
-
-                var cfg = tierData[variantId];
-                var tiers = cfg.tiers || [];
-                var basePrice = parseFloat(cfg.base_price) || 0;
-                if (tiers.length === 0 || basePrice <= 0) {
-                    return;
-                }
-
-                var $priceCell = $row.find('td[data-title="Price"]').first();
-                if (!$priceCell.length) $priceCell = $row.find('.woocommerce-Price-amount').first().closest('td');
-                var $priceEl = $priceCell.find('.woocommerce-Price-amount').first();
-                var $qty = $row.find('input.qty').first();
-                if (!$priceEl.length || !$qty.length) {
-                    return;
-                }
-
-                // Append a compact tier summary beneath the price
-                var tierListHtml = '<div class="studiou-fpp-row-tiers" title="' + escHtml(i18n.qtyTiersTitle) + '">';
-                tierListHtml += '<span class="dashicons dashicons-tag"></span>';
-                tierListHtml += '<span class="studiou-fpp-row-tiers-list">';
-                for (var i = 0; i < tiers.length; i++) {
-                    var t = tiers[i];
-                    tierListHtml += '<span class="studiou-fpp-row-tier">' + t.from_qty + '+: −' + formatPercent(t.percent) + '</span>';
-                }
-                tierListHtml += '</span></div>';
-                $priceCell.append(tierListHtml);
-
-                // Store the original price HTML so we can revert when qty falls below the first tier
-                if (!$priceEl.data('studiou-fpp-base-html')) {
-                    $priceEl.data('studiou-fpp-base-html', $priceEl.html());
-                }
-
-                // Bind qty change handlers to update this row's price display
-                var handler = function () {
-                    updateRowPrice($row, tiers, basePrice, $priceEl, $qty);
-                };
-                $qty.on('input change keyup', handler);
-                // pvtfw ± buttons sit next to the input; they dispatch change events on the input,
-                // but some themes intercept them — bind click too
-                $row.find('.qty-count').on('click', function () {
-                    setTimeout(handler, 0);
-                });
-
-                // Initial update (qty=1 usually, just to install the "Save X%" indicator if applicable)
-                updateRowPrice($row, tiers, basePrice, $priceEl, $qty);
-            });
+        function escAttr(str) {
+            return String(str == null ? '' : str).replace(/"/g, '&quot;');
         }
 
-        function updateRowPrice($row, tiers, basePrice, $priceEl, $qty) {
-            var qty = parseInt($qty.val(), 10) || 1;
-            var tier = resolveTierSimple(tiers, qty);
-            $row.find('.studiou-fpp-row-save-badge').remove();
-
-            if (!tier || tier.percent <= 0) {
-                // Revert to original HTML
-                var base = $priceEl.data('studiou-fpp-base-html');
-                if (base) {
-                    $priceEl.html(base);
-                }
-                return;
-            }
-
-            var unit = basePrice * (1 - tier.percent / 100);
-            $priceEl.html(formatPriceInner(unit));
-
-            var badge = '<span class="studiou-fpp-row-save-badge">−' + formatPercent(tier.percent) + '</span>';
-            $priceEl.after(badge);
+        function formatI18n(tpl, val) {
+            if (!tpl) return String(val);
+            return tpl.replace('%d', val).replace('%s', val);
         }
 
-        function resolveTierSimple(tiers, qty) {
-            var match = null;
-            for (var i = 0; i < tiers.length; i++) {
-                if (tiers[i].from_qty <= qty) match = tiers[i];
-                else break;
-            }
-            return match;
-        }
-
-        function formatPercent(percent) {
-            var p = parseFloat(percent) || 0;
-            var str = p.toFixed(2).replace(/\.?0+$/, '');
+        function formatPercent(p) {
+            var n = parseFloat(p) || 0;
+            var str = n.toFixed(2).replace(/\.?0+$/, '');
             return str + ' %';
         }
 
-        function formatPriceInner(amount) {
-            var curr = studiouWcfppFront.currency || {};
-            var decimals = parseInt(curr.decimals, 10);
-            if (isNaN(decimals)) decimals = 2;
-            var decSep = curr.decimalSeparator || '.';
-            var thouSep = curr.thousandSeparator || ',';
-            var fmt = curr.priceFormat || '%1$s%2$s';
-            var symbol = curr.symbol || '';
+        function formatPrice(amount) {
+            return formatPriceHtml(amount);
+        }
 
+        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 symbolHtml = '<span class="woocommerce-Price-currencySymbol">' + symbol + '</span>';
-            return fmt.replace('%1$s', symbolHtml).replace('%2$s', numStr);
-        }
-
-        function initQtyTiers() {
-            var $container = $('.studiou-fpp-qty-tiers-display');
-            var $form = $('form.variations_form').first();
-            if (!$container.length || !$form.length) {
-                return;
-            }
-
-            var currentTiers = [];
-            var currentBasePrice = 0;
-
-            $form.on('found_variation', function (event, variation) {
-                currentTiers = (variation && variation.studiou_fpp_qty_tiers) ? variation.studiou_fpp_qty_tiers : [];
-                currentBasePrice = (variation && variation.studiou_fpp_base_price) ? parseFloat(variation.studiou_fpp_base_price) : 0;
-                // WC re-renders .single_variation on every selection, so defer a tick to run after it
-                setTimeout(function () {
-                    renderTierTable();
-                    updateDisplayedPrice();
-                }, 0);
-            });
-
-            $form.on('reset_data', function () {
-                currentTiers = [];
-                currentBasePrice = 0;
-                $container.hide().empty();
-            });
-
-            // Live price update on quantity change — broad listener so themes with non-standard
-            // qty input placement still trigger the update.
-            $(document).on('input change keyup', 'input.qty, input[name="quantity"]', function () {
-                updateDisplayedPrice();
-            });
-
-            function renderTierTable() {
-                if (!currentTiers || currentTiers.length === 0) {
-                    $container.hide().empty();
-                    return;
-                }
-
-                var i18n = studiouWcfppFront.i18n;
-                var html = '<div class="studiou-fpp-qty-tiers-box">';
-                html += '<h4><span class="dashicons dashicons-tag"></span> ' + escHtml(i18n.qtyTiersTitle || 'Quantity Discount') + '</h4>';
-                html += '<table class="studiou-fpp-qty-tiers-table-display"><thead><tr>';
-                html += '<th>' + escHtml(i18n.qtyTiersFromQty || 'From Qty') + '</th>';
-                html += '<th>' + escHtml(i18n.qtyTiersDiscount || 'Discount') + '</th>';
-                html += '<th>' + escHtml(i18n.qtyTiersUnitPrice || 'Unit Price') + '</th>';
-                html += '</tr></thead><tbody>';
-
-                for (var i = 0; i < currentTiers.length; i++) {
-                    var t = currentTiers[i];
-                    var unit = computeDiscounted(currentBasePrice, t.percent);
-                    html += '<tr data-from-qty="' + t.from_qty + '">';
-                    html += '<td>' + t.from_qty + '+</td>';
-                    html += '<td>' + formatPercent(t.percent) + '</td>';
-                    html += '<td>' + formatPrice(unit) + '</td>';
-                    html += '</tr>';
-                }
-                html += '</tbody></table></div>';
-
-                $container.html(html).show();
-            }
-
-            function findPriceElement() {
-                var selectors = [
-                    '.single_variation .woocommerce-variation-price .woocommerce-Price-amount',
-                    'form.variations_form .woocommerce-variation-price .woocommerce-Price-amount',
-                    '.single_variation_wrap .woocommerce-Price-amount',
-                    'form.variations_form .price .woocommerce-Price-amount'
-                ];
-                for (var i = 0; i < selectors.length; i++) {
-                    var $el = $(selectors[i]).first();
-                    if ($el.length) return $el;
-                }
-                return $();
-            }
-
-            function findQtyInput() {
-                var $qty = $form.find('input.qty').first();
-                if (!$qty.length) $qty = $('input.qty').first();
-                if (!$qty.length) $qty = $('input[name="quantity"]').first();
-                return $qty;
-            }
-
-            function updateDisplayedPrice() {
-                if (!currentBasePrice || !currentTiers || currentTiers.length === 0) {
-                    highlightActiveTierRow(0);
-                    return;
-                }
-                var $qty = findQtyInput();
-                var qty = $qty.length ? (parseInt($qty.val(), 10) || 1) : 1;
-                var tier = resolveTier(currentTiers, qty);
-                var unit = tier ? computeDiscounted(currentBasePrice, tier.percent) : currentBasePrice;
-
-                var $wcPrice = findPriceElement();
-                if ($wcPrice.length) {
-                    $wcPrice.html(formatPriceInner(unit));
-                }
-
-                highlightActiveTierRow(tier ? tier.from_qty : 0);
-            }
-
-            function highlightActiveTierRow(fromQty) {
-                $container.find('tr').removeClass('studiou-fpp-qty-tier-active');
-                if (fromQty > 0) {
-                    $container.find('tr[data-from-qty="' + fromQty + '"]').addClass('studiou-fpp-qty-tier-active');
-                }
-            }
-
-            function resolveTier(tiers, qty) {
-                var match = null;
-                for (var i = 0; i < tiers.length; i++) {
-                    if (tiers[i].from_qty <= qty) {
-                        match = tiers[i];
-                    } else {
-                        break;
-                    }
-                }
-                return match;
-            }
-
-            function computeDiscounted(basePrice, percent) {
-                var p = parseFloat(percent) || 0;
-                if (p <= 0) return basePrice;
-                return basePrice * (1 - p / 100);
-            }
-
-            function formatPercent(percent) {
-                var p = parseFloat(percent) || 0;
-                // Strip trailing zeros
-                var str = p.toFixed(2).replace(/\.?0+$/, '');
-                return str + ' %';
-            }
-
-            function formatPrice(amount) {
-                return '<span class="woocommerce-Price-amount amount">' + formatPriceInner(amount) + '</span>';
-            }
-
-            function formatPriceInner(amount) {
-                var curr = studiouWcfppFront.currency || {};
-                var decimals = parseInt(curr.decimals, 10);
-                if (isNaN(decimals)) decimals = 2;
-                var decSep = curr.decimalSeparator || '.';
-                var thouSep = curr.thousandSeparator || ',';
-                var fmt = curr.priceFormat || '%1$s%2$s';
-                var symbol = curr.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 symbolHtml = '<span class="woocommerce-Price-currencySymbol">' + symbol + '</span>';
-                return fmt.replace('%1$s', symbolHtml).replace('%2$s', numStr);
-            }
+            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);

+ 153 - 7
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);
 
@@ -54,8 +60,25 @@ class Studiou_WC_FPP_Cart {
             }
         }
 
-        // Resolve upload reference from WC session (preferred) or first-party cookie
-        // (fallback for Store API / Cart-Token add-to-cart paths used by pvtfw 1.9.3+).
+        // 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) — resolve from WC session / legacy cookies.
         $resolved = Studiou_WC_FPP_Single_Product::resolve_uploaded_file($this->db);
         if (!$resolved) {
             return $cart_item_data;
@@ -66,16 +89,139 @@ class Studiou_WC_FPP_Cart {
         $cart_item_data['studiou_fpp_file_name']      = $resolved['file_name'];
         $cart_item_data['studiou_fpp_thumb_url']      = $resolved['thumb_url'];
 
-        // Update file record with variation
-        $actual_variation_id = $variation_id ?: $product_id;
         $this->db->update_file_record((int) $resolved['file_record_id'], array(
-            'variation_id' => $actual_variation_id,
+            'variation_id' => (int) ($variation_id ?: $product_id),
         ));
-
-        // Keep session / cookie intact so the same photo can be used for multiple variants
         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;
+        }
+
+        $record = $this->db->get_file_record($file_record_id);
+        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;
+        }
+
+        $product_id = (int) ($item['product_id'] ?? 0);
+        WC()->cart->remove_cart_item($cart_item_key);
+
+        wp_send_json_success(array(
+            'overview_html' => $this->render_overview_html($product_id),
+        ));
+    }
+
+    /**
+     * 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) {
         $url = $this->get_cart_item_thumb_url($cart_item);
         if ($url) {

+ 138 - 14
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-single-product.php

@@ -11,22 +11,73 @@ class Studiou_WC_FPP_Single_Product {
     public function __construct($db) {
         $this->db = $db;
 
-        // Render upload area below the variation table on single product page
+        // 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);
         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);
 
-        // AJAX: store uploaded file reference in WC session
+        // AJAX: store uploaded file reference in WC session (legacy — shortcode path)
         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'));
     }
 
+    /**
+     * Detach pvtfw's variant-table + available-options-button hooks on FPP products.
+     * pvtfw exposes its instances as globals $pvtfw_print_table and $pvtfw_available_btn;
+     * the table hook/priority is derived from the same option pvtfw itself reads
+     * (pvtfw_variant_table_place, default woocommerce_after_single_product_summary_9).
+     */
+    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;
+        }
+
+        global $pvtfw_print_table, $pvtfw_available_btn;
+
+        if ($pvtfw_print_table) {
+            $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($pvtfw_print_table, 'print_table'), $priority);
+                }
+            }
+        } elseif (defined('WP_DEBUG') && WP_DEBUG) {
+            error_log('STUDIOU FPP: $pvtfw_print_table not found; cannot detach pvtfw variant table.');
+        }
+
+        if ($pvtfw_available_btn) {
+            remove_action('woocommerce_single_product_summary', array($pvtfw_available_btn, 'available_options_btn'), 11);
+        }
+    }
+
+    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() {
         global $product;
         if (!$product || !Studiou_WC_FPP_Product::is_fpp_product($product->get_id())) {
@@ -36,28 +87,90 @@ 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 state (WC session or cookie fallback) to JS for restore on page load
-        $session_data = array();
-        $resolved = self::resolve_uploaded_file($this->db);
-        if ($resolved) {
-            $att_id  = (int) $resolved['attachment_id'];
+        // 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');
-            $session_data = array(
+            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_record_id' => (int) $resolved['file_record_id'],
-                'file_name'      => $resolved['file_name'],
-                'thumbnail_url'  => $thumb ? $thumb[0] : '',
-                'preview_url'    => $preview ? $preview[0] : ($thumb ? $thumb[0] : ''),
+                'file_name'      => (string) $row->file_name,
+                'thumb_url'      => $thumb ? $thumb[0] : ($preview ? $preview[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';
+        }
+    }
+
+    /**
+     * 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;
+                $label_parts[] = $term ? $term->name : $attr_value;
+            }
+            $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;
     }
 
     /**
@@ -88,6 +201,10 @@ class Studiou_WC_FPP_Single_Product {
 
     /**
      * 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);
@@ -95,6 +212,13 @@ class Studiou_WC_FPP_Single_Product {
             return $passed;
         }
 
+        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;
         }

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

@@ -269,6 +269,10 @@ 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'),
+                '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'),

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

@@ -0,0 +1,97 @@
+<?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'] ?? '';
+                $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;
+                        $bits[] = $term ? $term->name : $v;
+                    }
+                    $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 endif; ?>
+                    <div class="studiou-fpp-overview-body">
+                        <div class="studiou-fpp-overview-name"><?php echo esc_html($name); ?></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>

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