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