# CLAUDE.md - Studiou WC Free Photo Product ## Project Overview WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.5.14. Allows publishing special variable products where customers upload custom raw images to be printed for a price. ## Initial Requirements 1. WP (6.9.4) / WooCommerce (10.6.2) plugin with variants and custom raw image upload for photo printing. Frontend shows product with upload box and preview on the WooCommerce product detail page. 2. Product configuration area: Media Category for uploaded files, Maximum raw file size. Shortcode to embed product on any page. 3. Uploaded raw files stored in WP media library with a specific media category per product. Chunked upload to bypass PHP upload size limits. Media files linked with product. 4. Admin summary page: list of uploaded files, download as ZIP (bulk), order number, delete, change state (downloaded, ready, solved). 5. HPOS support and localization (English, Czech). 6. **1.5.0 change:** multi-upload — customers upload *many* photos in one visit, product page shows a vertical stack of cards (one per upload) instead of pvtfw's variant table. Each card has its own variant selector + qty + live price + Add-to-Cart button. Order-overview panel below lists this product's cart lines with per-line remove and subtotal. First uploaded file becomes the product hero image. ## Tech Stack - PHP 8.2+, WordPress 6.9.4+, WooCommerce 10.0+ - Hard plugin dependency: **Product Variant Table for WooCommerce** (`pvtfw`, folder slug `product-variant-table-for-woocommerce`) — required for non-FPP variable products across the shop. **Suppressed on FPP products** since 1.5.0 — our card stack is the sole Add-to-Cart surface there. Declared via the `Requires Plugins:` header and enforced at runtime by `Studiou_WC_Free_Photo_Product::is_pvtfw_active()`. - jQuery for frontend/admin JS - WordPress AJAX (admin-ajax.php) for chunked upload + per-card cart ops - First-party cookie `studiou_fpp_batch` as batch identifier — survives the classic-session <-> Store-API Cart-Token split - Custom DB table via dbDelta for file tracking (`wp_studiou_fpp_files`, with `batch_token` column indexed) - Custom taxonomy `studiou_media_category` on `attachment` post type ## Architecture - Main class: `Studiou_WC_Free_Photo_Product` in root plugin file - Constants prefix: `STUDIOU_WCFPP_` - CSS class prefix: `studiou-fpp-` - JS namespace: `studiouWcfppFront` (frontend), `studiouWcfpp` (admin) - Nonces: `studiou-wcfpp-front-nonce` (frontend), `studiou-wcfpp-nonce` (admin) - Text domain: `studiou-wc-free-photo-product` - Schema upgrade is automatic: `maybe_upgrade_db()` on plugin load compares `studiou_wcfpp_db_version` option against `STUDIOU_WCFPP_VERSION` and runs `dbDelta` on mismatch. ## Key Classes - `Studiou_WC_FPP_DB` - Custom table CRUD, media category taxonomy. Batch-aware queries: `get_batch_files($token, $product_id)`, `count_batch_files($token, $product_id)`. - `Studiou_WC_FPP_Product` - WC product data tab, product meta (including `_studiou_fpp_max_uploads` — per-product cap on simultaneous uploads, 0 = unlimited). - `Studiou_WC_FPP_Upload` - Chunked upload AJAX, file assembly, attachment creation. Mints/reuses a 32-char `studiou_fpp_batch` cookie on first chunk-upload completion and persists it in the new file record's `batch_token` column. Enforces the per-product max-uploads cap before accepting chunk 0. The batch cookie is the **sole** transport since 1.5.3 — legacy WC-session keys and the short-lived `studiou_fpp_att_id`/`studiou_fpp_rec_id` cookie pair have been removed. - `Studiou_WC_FPP_Single_Product` - On `wp` priority 5, detaches pvtfw's variant table (`remove_action()` on the hook/priority read from pvtfw's own `pvtfw_variant_table_place` option) and pvtfw's available-options button on FPP products. pvtfw's singletons are reached via `PVTFW_PRINT_TABLE::instance()` / `PVTFW_AVAILABE_BTN::instance()` (the `$pvtfw_print_table` / `$pvtfw_available_btn` globals published by pvtfw end up as method-locals because pvtfw's `require_once` lives inside a method — the static singletons return the same real instances that pvtfw used to register the hooks). On `woocommerce_single_product_summary` priority 35 renders the upload dropzone + card stack + order-overview panel. Exposes `resolve_batch_files(Studiou_WC_FPP_DB $db, $product_id = 0)` (list, 1.5.0 path) and `resolve_uploaded_file()` (single, legacy shortcode path). Adds `studiou-fpp-product-page` body class. Server-side validation (`woocommerce_add_to_cart_validation` priority 1) accepts `$cart_item_data['studiou_fpp_file_record_id']` as the primary "photo present" signal, with the resolver as fallback. - `Studiou_WC_FPP_Shortcode` - `[studiou_free_photo_product id="X"]` renders product with WC native variation form (unchanged — still the single-file flow). - `Studiou_WC_FPP_Cart` - New AJAX endpoints `studiou_wcfpp_add_file_to_cart` (per-card Add-to-Cart, pre-stamps `file_record_id` on `$cart_item_data` and calls `WC()->cart->add_to_cart()`) and `studiou_wcfpp_remove_cart_line` (cart-only remove from overview panel). `add_cart_item_data()` prefers pre-stamped `file_record_id` from the new flow, falls back to the legacy visitor-level resolver. Replaces cart/order item thumbnails: classic cart via `woocommerce_cart_item_thumbnail`; block cart (Store API) via `woocommerce_get_cart_item_from_session` which clones the cart line's product and calls `set_image_id()` on the clone — this is cart-item-scoped so multiple lines sharing a `variation_id` still render their own uploaded photo (the previous `woocommerce_product_get_image_id` override fired per-product and mismatched thumbnails when several lines shared a variation, fixed in 1.5.4). `render_overview_html($product_id)` returns the panel HTML for in-place re-render after cart changes. - `Studiou_WC_FPP_Admin` - Summary page with filters (status, product, order number, file-name search) and pagination, sortable columns (Order / Status / Date — default Date DESC), status management, ZIP download, CSV export, file download with auto-status-change, bulk actions with confirmation dialogs. - `Studiou_WC_FPP_Pricing` - Per-variation quantity discount tiers. Admin editor on `woocommerce_variation_options_pricing`, save via `woocommerce_save_product_variation`, runtime discount via `woocommerce_before_calculate_totals` (priority 20) using `set_price()` on the cart item's product instance. Tier meta stored as a PHP array in `_studiou_fpp_qty_tiers` on each `product_variation` post. Feature is gated to FPP-enabled parent products. **Frontend (1.5.0+):** tier data is consumed directly by the upload-card JS — each card's `updateCardPrice()` reads the selected variant's tiers (embedded in `studiouFppCardData.variants`) and re-renders its price + `−X%` badge on variant/qty changes. `initVariantTableTiers()` (pvtfw) and `initQtyTiers()` (native WC form) were removed in 1.5.0. ## Frontend Flow (1.5.0+) **FPP product page (`is_product()` + `is_fpp_product()`):** 1. `maybe_suppress_pvtfw()` detaches pvtfw's `print_table` and `available_options_btn` hooks on `wp` priority 5. 1a. (1.5.12+) `maybe_suppress_pvtfw()` also detaches `woocommerce_template_single_price` at priority 10 so WC's price-range span (e.g. "10,00 Kč – 40,00 Kč") is hidden on FPP products — the per-variant table below replaces it. 1b. (1.5.12+) `render_variant_price_table()` on `woocommerce_single_product_summary` priority 34 renders a read-only server-side table (`views/single-product-price-table.php`) above the dropzone: one row per variant with unit price (via `wc_price()`) and tiered quantity-discount badges. Pure overview — no radio/qty/add-to-cart. 1c. (1.5.12+) `override_wc_strings()` on `gettext_woocommerce` reroutes selected WC core strings (currently "Estimated total") through our own textdomain so the translation lives in our .po. This catches PHP-rendered strings only. (1.5.13+) The block cart is React-rendered and reads strings from `wp.i18n` instead, so an inline script on the `wp-i18n` handle calls `wp.i18n.setLocaleData({ 'Estimated total': […], 'Cart totals': […] }, 'woocommerce')` after wp-i18n loads — overriding the bundled "Odhadovaný součet" in the live DOM. 2. `render_upload_area()` on `woocommerce_single_product_summary` priority 35 passes variants + max uploads + existing batch files + hero preview URL to JS via `wp_localize_script('studiou-wcfpp-frontend', 'studiouFppCardData', ...)`. 3. Browser renders: - **Dropzone** (multi-file ``) — accepts drag-drop + click-to-browse. Click-to-browse opens the native file picker via `$fileInput[0].click()` (NOT `$fileInput.trigger('click')`), because the `` lives inside the dropzone element and a bubbling jQuery trigger re-entered the dropzone's own click handler, causing `Maximum call stack size exceeded` on mobile where the browser synthesizes an extra click when the picker closes (fixed in 1.5.11). - **Cards stack** — one card per upload, all built client-side by `buildCard()` from `studiouFppCardData`. - **Order overview panel** — rendered server-side initially from `WC()->cart` filtered to the current product; re-rendered in-place after each cart change. 4. Customer drops photos → `enqueueFiles()` creates a "uploading" card per file, queue processes sequentially via chunked AJAX. 5. Server-side upload handler: - `resolve_or_mint_batch_token()` — reads `studiou_fpp_batch` cookie or mints a new 32-char token and sets the cookie. - On chunk 0 of a new file, counts existing batch files for this product; rejects if `_studiou_fpp_max_uploads` is reached. - On final chunk: `assemble_chunks()` creates the attachment and the DB row with `batch_token` stamped. 6. On upload complete, JS swaps the card to "ready" state (thumb + variant dropdown + qty + price + Add to Cart + × Remove Upload). 7. If this is the first card in the stack, JS also swaps the product-gallery image to the upload preview via `applyHeroImage()`. Remove-first-upload reverts to the next card's image or restores the original via `restoreHeroImage()`. 8. Customer picks variant + qty on a card → `updateCardPrice()` computes (base_price × qty) with the applicable tier applied; shows `−X%` badge when a tier is active. 9. Click "Add to cart" → `studiou_wcfpp_add_file_to_cart` AJAX with `file_record_id + variation_id + quantity`. Server: - Verifies nonce + record ownership (`batch_token` match). - Pre-stamps `$cart_item_data['studiou_fpp_file_record_id']`. - Calls `WC()->cart->add_to_cart($parent_id, $qty, $variation_id, $variation_attrs, $cart_item_data)`. - Returns `overview_html` (freshly-rendered panel) for in-place replacement. 10. `woocommerce_add_cart_item_data` (our filter) reads the pre-stamped `file_record_id`, looks up the record, stamps `studiou_fpp_attachment_id` / `_file_name` / `_thumb_url` on the cart item, and updates the record's `variation_id`. Each (upload × variant × qty) Add-to-Cart creates a distinct cart line (WC's `generate_cart_id()` hashes `$cart_item_data`, so different `file_record_id`s never collapse). 11. `woocommerce_before_calculate_totals` priority 20 (`Pricing::apply_cart_discount`) re-applies tier discounts on every recalc — authoritative server-side pricing. 12. × on an upload card → `studiou_wcfpp_remove_upload` (deletes the DB row + attachment, clears legacy cookies). × on an overview line → `studiou_wcfpp_remove_cart_line` (cart-only; upload stays intact). 13. Cart/order thumbnails: classic cart via `woocommerce_cart_item_thumbnail`; block cart (Store API) via `woocommerce_get_cart_item_from_session` — we clone the cart line's product and `set_image_id()` it to the uploaded attachment, so each line renders its own photo even when several lines share a variation. 13a. **Lightbox (1.5.4+):** opens `.studiou-fpp-lightbox` with the attachment's `woocommerce_single` size preview. **Primary opener (1.5.7+):** an explicit "Enlarge" button — `.studiou-fpp-card-enlarge` in each card's price-row (sits left of Add-to-Cart, away from the top-right × remove button) and `.studiou-fpp-overview-enlarge` on each overview line. Buttons are wired via direct `element.onclick = onEnlargeClick` at creation/render time so theme-level click interceptors can't swallow them. **Secondary opener:** clicking the thumbnail itself (element-level listener on `.studiou-fpp-card-thumb` via `bindThumbClick`, plus a document-level capture-phase listener). Closed via × button, backdrop click, or Escape. Disabled on cards still uploading. If the overlay DOM somehow fails to initialize, `openLightbox()` falls back to `window.open(url, '_blank')` so the button is never a no-op. 14. Order admin: thumbnail via `woocommerce_admin_order_item_thumbnail`, download link via `woocommerce_after_order_itemmeta` (auto-marks status as "downloaded"). 15. On checkout, file records linked to the order via `woocommerce_checkout_order_processed` (classic), `woocommerce_store_api_checkout_order_processed` (block), `woocommerce_thankyou` (fallback). Each cart line becomes its own order item with its own photo meta. 16. **Shortcode path `[studiou_free_photo_product id="X"]`** keeps the single-file 1.3.x UX — one dropzone, one preview, one `attachment_id`, add-to-cart via WC's native `variations_form`. The pvtfw suppression doesn't touch shortcode-embedded products (they're not rendered via `single-product.php`). ## Coding Conventions - Follow patterns from sibling plugin `studiou-wc-product-cat-manage` - All PHP files start with `if (!defined('WPINC')) { die; }` - AJAX handlers: verify nonce, check capabilities, try/catch with WP_DEBUG logging - Output buffer cleanup in AJAX handlers - Use `wp_send_json_success` / `wp_send_json_error` for all AJAX responses - All user-facing strings wrapped in `__()` or `esc_html_e()` with text domain - Sanitize all inputs: `absint()`, `sanitize_text_field()`, `sanitize_file_name()` - Escape all outputs: `esc_html()`, `esc_attr()`, `esc_url()`, `wp_kses_post()` ## Build / Deploy Notes - Generate .mo from .po: `php languages/generate-mo.php` or `wp i18n make-mo languages/` - Plugin creates DB table on activation via `register_activation_hook` - **Schema upgrades are automatic on load** (no manual migration needed): `maybe_upgrade_db()` calls `create_tables()` → `dbDelta` whenever `studiou_wcfpp_db_version` is older than `STUDIOU_WCFPP_VERSION`. The 1.5.0 upgrade adds the `batch_token` column + index to existing installs transparently. - Temp chunks stored in `wp-content/uploads/studiou-fpp-chunks/` (cleaned on deactivation) - Assembled files stored in `wp-content/uploads/YYYY/MM/free-photo/` ## Version Bumping When changing JS or CSS files, always bump the plugin version in the main PHP file header AND the `STUDIOU_WCFPP_VERSION` constant to bust browser cache. Also bump the version string in `languages/studiou-wc-free-photo-product.pot` and `languages/studiou-wc-free-photo-product-cs_CZ.po` header, and update the `v…` reference at the top of this CLAUDE.md. A version bump also triggers `maybe_upgrade_db()` → `dbDelta` on next load, which is idempotent.