Parcourir la source

add implementation instructions for multiupload functionality

Dalibor Votruba il y a 2 mois
Parent
commit
9348551f0a
1 fichiers modifiés avec 351 ajouts et 0 suppressions
  1. 351 0
      studiou-wc-free-photo-product/multiupload-photo-instruction.md

+ 351 - 0
studiou-wc-free-photo-product/multiupload-photo-instruction.md

@@ -0,0 +1,351 @@
+# 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`).
+
+```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).
+- `<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`):
+
+```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 + `<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 cookies** — `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.
+- **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.*