multiupload-photo-instruction.md 25 KB

Multi-upload Photo Product — Implementation Analysis

Target plugin: studiou-wc-free-photo-product, baseline v1.3.6 Target version: 1.4.0 Author: Dalibor Votruba (design doc) — 2026-04-16


1. Executive summary

Today the plugin lets a shopper upload one photo per product-detail visit and then reuse it across any number of variants via the Product Variant Table for WooCommerce (pvtfw) rendering.

The new model flips the axis: shoppers upload many photos, and the product-detail page shows the pvtfw variant table replaced by a vertical stack of cards, one card per uploaded photo. Each card is self-contained: thumbnail + filename + variant selector + quantity + live price with discount flag + Add-to-Cart button + Remove-upload button. Clicking Add to Cart on any card creates a new order item for that (upload × variant × qty) combination. Below the cards, a live order-overview panel summarises what the shopper has added for this product. The first uploaded image becomes the product's hero thumbnail on this page.

This is a fundamental inversion of rendering axis, cart identity, and session transport — so the analysis below is worth settling before writing code.


2. Current architecture (baseline)

Concern v1.3.6 behavior
Upload Chunked ajax (studiou_wcfpp_upload_chunk), one file at a time, the latest upload replaces the previous one.
Identity One file_record_id in wp_studiou_fpp_files; transport via WC session and studiou_fpp_att_id / studiou_fpp_rec_id cookie pair (1.3.5 fix).
Product page pvtfw renders <table class="variant">, one row per variation. Our JS wires tier-discount display under each row.
Add to cart pvtfw's own button; validator (woocommerce_add_to_cart_validation priority 1) blocks if no uploaded photo is resolvable; woocommerce_add_cart_item_data stamps the single upload's IDs on the cart item.
Hero image JS updateProductGallery() replaces the first .woocommerce-product-gallery__image img with the upload preview.
Tiers Emitted as window.studiouFppTierData, applied on pvtfw rows client-side, authoritative server-side via woocommerce_before_calculate_totals priority 20.

Single-file invariant is baked into: cookie schema (two ints), resolver (resolve_uploaded_file), cart-item-data filter (reads session/cookie, not from the click), gallery swap, upload UI (one dropzone + one preview slot).


3. Proposed architecture

3.1 Shift in identity — batch of uploads

Per-visitor state becomes a batch of uploaded files, not a single upload.

Introduce a batch token — a single opaque random string (wp_generate_password(32, false)) that identifies everything this visitor has uploaded in the current shopping session.

  • Written to one HttpOnly first-party cookie studiou_fpp_batch on the first chunk-upload completion, path /, same-site Lax, ~1 day expiry.
  • Reused by every subsequent upload from the same browser (not rotated until all-batch remove, or checkout).
  • Persisted as a new column batch_token VARCHAR(32) on wp_studiou_fpp_files, indexed, filled on row insert.
  • Survives the Store API / Cart-Token split for the same reason the 1.3.5 cookies did: cookies travel with any same-origin request, JWTs don't.

The resolver changes shape: instead of returning one record, it returns the list of file records belonging to the current batch that are not yet bound to an order (order_id = 0).

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:

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-sideStudiou_WC_FPP_Upload::handle_chunk_upload barely changes:

  • Continue to process one file per request (chunked).
  • On final-chunk assembly, determine batch token: read studiou_fpp_batch cookie; if missing/stale, mint a new one and set the cookie. Write the token into the new row's batch_token.
  • Enforce the per-product max-uploads cap (see §3.9): before accepting the first chunk of a new file, count current batch rows for this product; reject with an error if the cap is hit.
  • Drop the legacy two-int cookie writes (kept only as read-fallback for one release — see §6).

Client-side — reworked assets/js/frontend.js:

  • Replace the single-preview UI with the upload cards region (§3.4).
  • <input type="file" multiple> on the dropzone; drop events support multiple files.
  • On select/drop with N files, enqueue them and upload sequentially (parallel upload is a phase-2 tweak — sequential keeps chunk semantics simple and avoids server load spikes).
  • While a file is uploading, its card shows a progress bar and disables the variant/qty/add-to-cart controls.
  • On successful upload: swap the card into "ready" state with thumbnail, filename, variant dropdown, qty, Add-to-Cart button, and a × Remove-upload button.
  • On remove-upload click: call studiou_wcfpp_remove_upload (per-record removal already exists in the current API), then drop the card from the DOM.

3.4 Upload cards (replaces pvtfw table on FPP products)

This is where the UX pivots. Instead of a row-per-variation table, the page shows a single vertical stack of cards, one card per uploaded file. Each card combines the roles of "upload queue item" and "ordering row" — there is no separate queue region.

3.4.1 pvtfw suppression — resolved

pvtfw registers its table via (from inc/frontend/class_pvtfw_print_table.php:367):

$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:

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:

if (!empty($cart_item_data['studiou_fpp_file_record_id'])) {
    $record = $this->db->get_file_record((int) $cart_item_data['studiou_fpp_file_record_id']);
    if ($record && $record->order_id == 0) {
        // stamp attachment_id, file_name, thumb_url on cart_item_data from record
        // update the record's variation_id (the most recently chosen one wins)
        return $cart_item_data;
    }
}
// legacy/shortcode fallback
return $this->legacy_add_cart_item_data($cart_item_data, $product_id, $variation_id);

Raw image stays single. Per decision #3: the same attachment_id can be referenced by multiple cart lines and multiple order items. The media file (the raw image) exists exactly once in the media library — print source is single even if the shopper prints it in 3 sizes. This is already how the current code works; nothing new.

3.6 Order-overview panel

Rendered below the upload-cards stack on the same page. Template lives in views/single-product-order-overview.php. Scope: only cart lines for the current product (decision #4) — the shopper sees what they've composed for this photo product, not their global cart.

Per-line display (read-only — decision #6):

  • Upload thumbnail (from cart item meta _studiou_fpp_thumb_url)
  • Variation label (wc_get_formatted_variation($variation) or $variation->get_formatted_name())
  • Quantity — plain display, non-editable
  • Unit price + line total with −X% badge if a tier is active (the cart line already carries the discounted unit price because Studiou_WC_FPP_Pricing::apply_cart_discount has run on woocommerce_before_calculate_totals priority 20)
  • Remove link — cart removal only (decision #5). Clicking removes the line via our AJAX endpoint or WC's native ?remove_item=...&_wpnonce=...; it does not delete the upload. The upload itself is removable only via the × button on its upload card (§3.4.2).

Bottom of panel: subtotal for the filtered (current-product) lines + a "Go to checkout" button linking to wc_get_checkout_url().

Rendering: server-rendered initial state on page load; JS re-renders after each Add-to-Cart (using overview_html returned from studiou_wcfpp_add_file_to_cart). The panel listens to a custom DOM event (studiou-fpp:cart-updated) so remove handlers trigger a refresh.

3.7 Tiers in the card layout

Data model unchanged — tiers remain per-variation (_studiou_fpp_qty_tiers). What changes is the UI wiring:

  • Studiou_WC_FPP_Pricing::emit_tier_data_script() keeps emitting window.studiouFppTierData keyed by variation_id. No change.
  • initVariantTableTiers() goes away; a new initUploadCards() takes over. For each card, on change of the variant dropdown or input/change/click on the qty controls, recompute discounted_unit = base_price * (1 − tier_percent/100) and update the card's price + badge.
  • Server-side authority unchanged: apply_cart_discount on woocommerce_before_calculate_totals priority 20 is still the only voice that matters for the real cart total.

3.8 First uploaded file as product thumbnail

Simplest implementation = JS-only, a direct extension of the current updateProductGallery().

  • On page load: server passes the first resolved batch file's preview_url to studiouWcfppSession.hero_preview_url via wp_localize_script.
  • JS runs updateProductGallery(heroPreviewUrl) if present — same selectors it uses today.
  • On each successful upload: if this is the first card in the stack, call updateProductGallery() with the new file's preview.
  • On remove-first-upload: if there is a remaining card, swap the gallery to the new first; otherwise call restoreProductGallery().

The server-side woocommerce_product_get_image_id filter is not used on the product-detail page itself — too risky, fires in many contexts (archive loops, related products, REST). JS stays scoped to the current page.

3.9 Product-level max-uploads setting

New post meta _studiou_fpp_max_uploads on the variable product, admin-edited in the existing Free Photo product tab alongside _studiou_fpp_max_file_size. Default 0 = unlimited (decision #1 — "conditionally restricted and defined on product").

Enforcement:

  • Server: handle_chunk_upload counts current batch rows for this product before accepting chunk 0 of a new upload. Over the cap → wp_send_json_error('Max uploads reached.').
  • Client: initUploadCards disables the dropzone and shows a hint "Max N photos reached" when the count == cap.

4. File-by-file impact

File Kind Change
studiou-wc-free-photo-product.php PHP Bump to 1.4.0 + STUDIOU_WCFPP_VERSION. Extend admin i18n block.
includes/class-studiou-wc-fpp-db.php PHP Add batch_token column + index in create_tables(). Add get_file_records_by_batch($token, $product_id=0). Bump studiou_wcfpp_db_version.
includes/class-studiou-wc-fpp-upload.php PHP Replace single-file cookie pair with studiou_fpp_batch. Mint-or-reuse batch token on assembly; write it into the new row. Enforce _studiou_fpp_max_uploads. Remove cookie on full-batch remove (new endpoint).
includes/class-studiou-wc-fpp-single-product.php PHP Replace "upload area" render with "dropzone + upload-cards stack + order-overview" composite. New resolve_batch_files() helper. Attach the pvtfw suppression add_action('wp', …) described in §3.4.1.
includes/class-studiou-wc-fpp-cart.php PHP New ajax_add_file_to_cart() + ajax_remove_cart_line(). Rewrite add_cart_item_data() to prefer pre-stamped file_record_id; keep legacy resolver as fallback.
includes/class-studiou-wc-fpp-pricing.php PHP Keep emit_tier_data_script; drop pvtfw-specific DOM wiring (moves to the new JS module).
includes/class-studiou-wc-fpp-product.php PHP Add the "Max uploads" field in the admin tab (alongside max file size / resolution limits). Save/sanitize with the other meta.
views/single-product-upload.php PHP/HTML Rewrite. Dropzone + upload-cards stack + order-overview container + <template id="studiou-fpp-card-tpl"> for client-side clone.
views/single-product-order-overview.php PHP/HTML New. Server-rendered initial state of the order overview.
assets/js/frontend.js JS Substantial rewrite. New modules: uploadCards() (unified upload + order row), orderOverview(), heroImage(). Delete initVariantTableTiers(), initQtyTiers().
assets/css/frontend.css CSS Card styles (thumbnail, controls, buttons, hover/disabled states), order-overview layout, discount badge.
CLAUDE.md, readme.md docs Describe new mental model, pvtfw suppression, hooks, endpoints.
languages/*.pot, *.po i18n New strings: "Drop photos here", "Remove upload", "Add to cart", "Your order", "Go to checkout", "Max uploads reached (%d)", etc.

5. Hook / endpoint inventory

New

  • wp_ajax_studiou_wcfpp_add_file_to_cart / nopriv — per-card Add to Cart
  • wp_ajax_studiou_wcfpp_remove_cart_line / nopriv — remove single cart line from the order overview (cart-only, does not touch upload)
  • wp (priority ~0) — conditional pvtfw suppression on FPP products (§3.4.1)

Changed

  • woocommerce_add_to_cart_validation (priority 1) — now checks $cart_item_data['studiou_fpp_file_record_id'] first; fallback to resolver
  • woocommerce_add_cart_item_data — now reads pre-stamped file_record_id first; fallback to resolver

Retired (post-migration)

  • studiou_wcfpp_set_upload_session / studiou_wcfpp_clear_upload_session AJAX handlers — cookie-only now

Unchanged

  • Chunk upload internals (studiou_wcfpp_upload_chunk)
  • woocommerce_before_calculate_totals tier application
  • Cart/order thumbnail filters (woocommerce_cart_item_thumbnail, Store API image overrides)
  • Order-item linking (woocommerce_checkout_order_processed, …store_api_checkout_order_processed, …thankyou)

6. Backward compatibility & migration

  • Shortcode ([studiou_free_photo_product id="X"]) — lives on arbitrary pages, not the WC product-detail, so the pvtfw suppression doesn't touch it. Keep its current single-file UX as-is: single dropzone, single-file resolver fallback, WC native variations form. The new card-stack UX is specifically for the product-detail page.
  • Legacy cookiesstudiou_fpp_att_id / studiou_fpp_rec_id kept as read-fallback in the resolver for one release so shoppers mid-session during the upgrade don't lose their upload. Removed in v1.4.1.
  • DB rows without batch_token — left as-is. Already-ordered or orphaned; new uploads go through the new path.
  • Already-placed orders — no migration needed. Order-item meta is per-line and has been multi-line capable since the beginning.
  • Existing _studiou_fpp_qty_tiers — untouched.

7. Decisions (resolved)

# Decision Resolution
1 Max uploads per visit Product-level setting _studiou_fpp_max_uploads (default 0 = unlimited), edited in the Free Photo product tab next to Max file size. Enforced both client-side and in handle_chunk_upload.
2 Upload queue persistence on reload Yes. Server rehydrates the card stack from resolve_batch_files($db) on page load; batch cookie persists across reloads.
3 Same file, multiple variants Yes — by explicit repeat add. Each Add-to-Cart creates a new cart line / order item. Raw image remains single in the media library (one attachment_id, referenced by multiple order items). No "duplicate card" button — shopper adds the same card multiple times with different variant/qty selections.
4 Order-overview scope Current product only. Cart lines filtered by product_id = current product.
5 Remove on overview Cart remove only. Upload removal lives on the upload card's × button, not on the order-overview line.
6 Inline qty edit on overview No. Shopper removes the line and re-adds with a new qty from the upload card.
7 Mobile layout Stacked cards below 640 px. The card layout is already a vertical stack at all breakpoints; mobile pass only tightens internal gutters and may wrap variant/qty/button row.
8 pvtfw suppression method Clean remove_action() on wp hook — §3.4.1. Uses pvtfw's own pvtfw_variant_table_place option to discover the hook/priority; also removes the "available options" button. No CSS fallback needed (pvtfw's hook names are stable API).

8. Phased rollout

Shippable in one release, reviewable in phases:

  1. DB + batch-token refactor. Add batch_token column, new resolver returning list, replace cookie pair. Zero UI change — regression-test single-file flow.
  2. Upload cards stack (UI). Suppress pvtfw table on FPP products, render the card stack, wire multi-upload + drag-drop. Still uses legacy single-file add-to-cart path under the hood (one card, one add).
  3. Per-card Add to Cart. New AJAX endpoint, per-card wiring, removes the session-based cart transport.
  4. Order overview + hero-image swap. The two final UI pieces. Both additive over phase 3.
  5. Docs + i18n. Update CLAUDE.md, readme.md, *.po/*.pot, regenerate .mo.

Recommended commits: one per phase, final one for docs + version bump. Target version: 1.4.0 — significant but additive (no public API removed yet; legacy cookies tolerated for one release).


9. Risks & mitigations

Risk Mitigation
pvtfw internal hook names change in a future version → our remove_action() silently mis-targets, the table re-appears alongside our cards. The hook/priority is derived from pvtfw's own option, so re-configurations flow through automatically. If pvtfw changes the option name itself, we'll notice (prominent visual glitch). A WP_DEBUG warning when $pvtfw_print_table is missing catches class renames.
Batch cookie collision across shoppers sharing a browser profile / proxy. 32-char random token → collision probability negligible. Cookies are per-browser.
Upload-in-flight + Add-to-Cart race. Card is rendered in disabled state during upload; Add-to-Cart button only enabled once the record row exists.
Order-admin summary shows many rows per order now. Already works — admin paginates per file record. Consider an order-grouping toggle in a future phase.
Max-uploads bypass by skipping JS. Enforced server-side in handle_chunk_upload before accepting chunk 0 of a new file.
Discount-tier UI lag on fast qty changes. Debounce recompute at 50 ms; server-side pricing remains authoritative.
Block cart renders "same product" many times (each card produces its own cart line). Same as today's multi-variant flow. Verified to work in 1.3.5.

10. Out of scope

  • In-browser cropping / rotating / editing of uploaded photos.
  • Per-file variant overrides (e.g. "this file can only print 10×15").
  • Upload resume across sessions.
  • Saving upload batches for logged-in users to revisit later.

If any of these move into scope, re-open this doc before coding.


End of document.