Target plugin: studiou-wc-free-photo-product, baseline v1.3.6
Target version: 1.4.0
Author: Dalibor Votruba (design doc) — 2026-04-16
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.
| 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).
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.
studiou_fpp_batch on the first chunk-upload completion, path /, same-site Lax, ~1 day expiry.batch_token VARCHAR(32) on wp_studiou_fpp_files, indexed, filled on row insert.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).
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.
Server-side — Studiou_WC_FPP_Upload::handle_chunk_upload barely changes:
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.Client-side — reworked assets/js/frontend.js:
<input type="file" multiple> on the dropzone; drop events support multiple files.× Remove-upload button.studiou_wcfpp_remove_upload (per-record removal already exists in the current API), then drop the card from the DOM.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.
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.
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).wp_get_attachment_image_src($att_id, 'thumbnail') server-side, or the URL returned by the upload AJAX for just-uploaded cards.$product->get_children() with embedded data-variation-id, data-base-price, and tier JSON (see §3.7) per option.−/+ buttons — same visual language as pvtfw's .qty-count for continuity.[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.
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:
order_id != 0), or if its batch_token doesn't match the caller's studiou_fpp_batch cookie.$variation_attributes = $variation->get_variation_attributes().$cart_item_data['studiou_fpp_file_record_id'] = $file_record_id so add_cart_item_data knows exactly which upload this line is for.WC()->cart->add_to_cart($parent_id, $qty, $variation_id, $variation_attributes, $cart_item_data).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.
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):
_studiou_fpp_thumb_url)wc_get_formatted_variation($variation) or $variation->get_formatted_name())−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_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.
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.apply_cart_discount on woocommerce_before_calculate_totals priority 20 is still the only voice that matters for the real cart total.Simplest implementation = JS-only, a direct extension of the current updateProductGallery().
preview_url to studiouWcfppSession.hero_preview_url via wp_localize_script.updateProductGallery(heroPreviewUrl) if present — same selectors it uses today.updateProductGallery() with the new file's preview.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.
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:
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.').initUploadCards disables the dropzone and shows a hint "Max N photos reached" when the count == cap.| 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. |
wp_ajax_studiou_wcfpp_add_file_to_cart / nopriv — per-card Add to Cartwp_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)woocommerce_add_to_cart_validation (priority 1) — now checks $cart_item_data['studiou_fpp_file_record_id'] first; fallback to resolverwoocommerce_add_cart_item_data — now reads pre-stamped file_record_id first; fallback to resolverstudiou_wcfpp_set_upload_session / studiou_wcfpp_clear_upload_session AJAX handlers — cookie-only nowstudiou_wcfpp_upload_chunk)woocommerce_before_calculate_totals tier applicationwoocommerce_cart_item_thumbnail, Store API image overrides)woocommerce_checkout_order_processed, …store_api_checkout_order_processed, …thankyou)[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.studiou_fpp_att_id / studiou_fpp_rec_id kept as read-fallback in the resolver for one release so shoppers mid-session during the upgrade don't lose their upload. Removed in v1.4.1.batch_token — left as-is. Already-ordered or orphaned; new uploads go through the new path._studiou_fpp_qty_tiers — untouched.| # | 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). |
Shippable in one release, reviewable in phases:
batch_token column, new resolver returning list, replace cookie pair. Zero UI change — regression-test single-file flow.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).
| 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. |
If any of these move into scope, re-open this doc before coding.
End of document.