Dalibor Votruba 1efef13658 Merge branch '1.0.0.studiou-wc-free-photo-product' преди 2 месеца
..
.claude 06faf7bf00 Add photo lightbox + fix block-cart thumbnail mismatch — v1.5.3 -> v1.5.8 преди 2 месеца
assets 224a12b555 Variant price table above dropzone + rename cart total — v1.5.11 -> v1.5.12 преди 2 месеца
includes 224a12b555 Variant price table above dropzone + rename cart total — v1.5.11 -> v1.5.12 преди 2 месеца
languages c9cbb9d764 Harden cart total i18n override against bundle clobber — v1.5.13 -> v1.5.14 преди 2 месеца
views 224a12b555 Variant price table above dropzone + rename cart total — v1.5.11 -> v1.5.12 преди 2 месеца
CLAUDE.md c9cbb9d764 Harden cart total i18n override against bundle clobber — v1.5.13 -> v1.5.14 преди 2 месеца
instructions.txt 4f646728ce compile _cz.mo преди 3 месеца
multiupload-photo-instruction.md 9348551f0a add implementation instructions for multiupload functionality преди 2 месеца
readme.md c9cbb9d764 Harden cart total i18n override against bundle clobber — v1.5.13 -> v1.5.14 преди 2 месеца
studiou-wc-free-photo-product.php c9cbb9d764 Harden cart total i18n override against bundle clobber — v1.5.13 -> v1.5.14 преди 2 месеца

readme.md

QDR - Studiou WC Free Photo Product

WordPress (6.9.4+) / WooCommerce (10.6.2+) plugin that allows publishing special variable products with custom raw image upload for photo printing. Customers upload one or more photos on the product page and compose an order where each uploaded photo can be printed in a different variant at its own quantity. Uploaded files are stored in the WordPress media library under a configurable media category.

What's new in 1.5.14

  • Cart "Estimated total" rename hardened against clobber. The 1.5.13 override on wp-i18n ran before WC's wc-cart-checkout-base-js-translations inline bundle, which then merged its own "Estimated total": ["Odhadovaný součet"] locale data on top and restored the original text. Now re-applies the override as an after-inline script on wc-cart-checkout-base (and wc-cart-block-frontend), plus a DOMContentLoaded safety net, so our value wins regardless of script order.

What's new in 1.5.13

  • Cart "Estimated total" rename now actually reaches the block cart. The 1.5.12 gettext_woocommerce PHP filter only catches PHP-rendered strings, but the WC block cart is React-rendered and pulls its labels from a separate wc-cart-checkout-base-js-translations bundle via wp.i18n.__(). Added an inline script on the wp-i18n handle that calls wp.i18n.setLocaleData({ 'Estimated total': […] }, 'woocommerce') as soon as wp-i18n loads, overriding the bundled Czech translation "Odhadovaný součet" with our own. Translation value still lives in our .po so it's locale-aware. Also remapped "Cart totals" for consistency.

What's new in 1.5.12

  • Read-only variant price table above the dropzone. Customers see all variant prices plus the tiered quantity-discount schedule before they upload. Server-side table uses wc_price() so formatting follows the WC pipeline. Out-of-stock rows greyed out. Rendered on woocommerce_single_product_summary priority 34 (right before the upload area at priority 35).
  • WC price-range hidden on FPP products. The "10,00 Kč – 40,00 Kč" span that WC outputs by default is suppressed via remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_price', 10) — the new variant table already communicates per-variant pricing.
  • Cart "Estimated total" → "Total" (cs_CZ: "Součet"). gettext_woocommerce filter reroutes WC's core string through our own textdomain so the translation sits in our .po and stays locale-aware.

What's new in 1.5.11

  • Mobile upload fix. Tapping the dropzone on mobile raised Maximum call stack size exceeded and prevented the file picker from opening. The <input type="file"> lives inside the dropzone, so $fileInput.trigger('click') dispatched a bubbling jQuery event that re-entered the dropzone's own click handler in an infinite loop (mobile surfaces it because the browser synthesizes an extra click when the picker closes). Fix: open the picker via native $fileInput[0].click() (no bubbling jQuery event) and bail out of the dropzone handler when the click originates on the input itself.

What's new in 1.5.10

  • Clean currency rendering in the variant combobox. WooCommerce's get_woocommerce_currency_symbol() returns as &#75;&#269; and the Czech get_woocommerce_price_format() as %2$s&nbsp;%1$s. Both values flowed into formatPricePlain(), whose result is assigned via jQuery .text(), so entities rendered verbatim as 10,00&nbsp;&#75;&#269; in <option> labels. Decoded at the PHP localize step with html_entity_decode(..., ENT_QUOTES | ENT_HTML5, 'UTF-8') before handing to JS.

What's new in 1.5.9

  • HTML entities in variant attribute labels. Term names or non-taxonomy attribute values stored in the WP database sometimes carry entities (&nbsp;, &#75;, &aacute;, …) pasted in from rich editors. Since <option> text is literal, they leaked into the variant combobox as-is. build_variant_list() (JS combobox data) and the order-overview template now decode entities (and urldecode non-taxonomy values) before building the label.

What's new in 1.5.8

  • Explicit "Enlarge" button on every upload card (in the price-row next to Add-to-Cart) and every order-overview line. Opens the lightbox with the woocommerce_single-size preview so customers can check image detail before committing. The button sits clear of the × remove button and is wired via direct element.onclick + jQuery delegation to survive any theme-level click interceptors. If the overlay DOM fails to initialize for any reason, the button falls back to opening the preview in a new browser tab instead of being a no-op. Thumbnail clicks remain as a secondary opener.
  • Cart-page thumbnail fix (1.5.4+). Multiple cart lines that share a variation_id now each render their OWN uploaded photo in the block cart. Previously the woocommerce_product_get_image_id filter fired per-product and could only return the first matching line's image, so lines sharing a variation all showed the same photo. The new woocommerce_get_cart_item_from_session approach clones the cart line's product instance and stamps set_image_id() per line, keeping the block cart and classic cart in sync with the admin order view.

What's new in 1.5.0

Multi-upload overhaul. The product-detail page now shows a vertical stack of cards — one per uploaded photo — in place of the pvtfw variant table on FPP products. Each card carries its own variant selector, quantity stepper, live tier-discounted price (with −X% badge), and its own Add-to-Cart button. An order-overview panel below the cards lists the lines composed for this product, with per-line remove and subtotal. The first uploaded image becomes the product hero thumbnail on that page. A per-product Max Uploads setting caps how many photos a customer can queue in one visit (default 0 = unlimited).

Requirements

  • WordPress 6.9.4+
  • WooCommerce 10.0+
  • PHP 8.2+
  • PHP ZipArchive extension (for admin ZIP download)
  • Product Variant Table for WooCommerce plugin (folder slug: product-variant-table-for-woocommerce) — still required as a dependency for non-FPP variable products across the shop. On FPP products the plugin suppresses pvtfw's rendering (via remove_action() on the hook/priority it reads from pvtfw's own pvtfw_variant_table_place option) and replaces it with its own upload-card stack. Declared via the plugin header Requires Plugins: field and enforced at runtime by an admin notice if the plugin is missing or deactivated.

Installation

  1. Upload the studiou-wc-free-photo-product folder to /wp-content/plugins/
  2. Activate the plugin through the Plugins menu in WordPress
  3. The plugin creates a custom database table {prefix}studiou_fpp_files on activation

Configuration

Creating a Free Photo Product

  1. Create or edit a WooCommerce product and set its type to Variable
  2. Add variations with attributes (e.g. size: 10x15, 20x30, A4) and prices
  3. Go to the Free Photo tab in the product data panel (visible for Variable products)
  4. Check Enable Free Photo
  5. Select or create a Media Category - uploaded files will be assigned to this category
  6. Set Max File Size (MB) - maximum allowed upload size per file (1-500 MB, default 50)
  7. Optionally set Min Image Resolution (px) and Max Image Resolution (px) - width and height are interchangeable (portrait/landscape auto-detected). Leave empty to skip validation. RAW files that cannot be measured are skipped. 7.5. Optionally set Max Uploads — cap on simultaneous uploads per customer visit for this product. Leave empty or 0 for unlimited.
  8. Copy the displayed Shortcode for use on custom pages

Product Detail Page (1.5.0+)

On FPP products, the plugin suppresses pvtfw's variant table (via remove_action() on pvtfw's configured place hook) and renders its own UI in its stead:

  1. An upload dropzone (multi-file) at the top of the area.
  2. A vertical stack of cards, one per uploaded photo. Each card contains: thumbnail + filename + remove-upload (×) button + variant selector + −/+ quantity stepper + live tier-discounted price with −X% discount badge + Add-to-Cart button.
  3. An order-overview panel at the bottom scoped to this product's FPP cart lines: thumbnail + variant label + quantity + line total + discount badge + per-line remove link + subtotal + "Go to checkout" button.

The customer workflow is:

  1. Drop or pick one or more photos — they upload sequentially in the same card stack (progress bar overlaid on the thumbnail while uploading).
  2. The first uploaded photo becomes the product's hero gallery image; it's swapped back if all photos are removed.
  3. For each card: pick a variant and a quantity — the price updates live, a −X% badge appears when a quantity-discount tier is active.
  4. Click Add to cart on any card — the backing AJAX pre-stamps the file_record_id on $cart_item_data and calls WC()->cart->add_to_cart(). A new cart line (and eventually a new order item) is created for each (photo × variant × qty) combination. Adding the same combination twice increments the existing line's quantity.
  5. The order-overview panel refreshes in place with the new line.
  6. × on a card removes the upload (deletes the media file and DB record). × on an overview line removes the cart line only — the upload stays in the card stack so it can be re-added with different options.

Identity transport (1.5.0). The plugin uses a single first-party HttpOnly cookie studiou_fpp_batch holding a 32-character random token. The token is minted the first time a photo finishes uploading and is reused by every subsequent upload in the same browser. Each wp_studiou_fpp_files row carries this token in a new batch_token column, so the server can enumerate exactly the photos this visitor has uploaded regardless of which WooCommerce session handler answers the Add-to-Cart request (classic cookie session vs. Store API Cart-Token — the cookie travels with every same-origin request). The legacy single-file studiou_fpp_att_id / studiou_fpp_rec_id cookies are still written by the upload handler as a read-fallback for the shortcode path; they'll be removed in 1.5.1.

Shortcode path. [studiou_free_photo_product id="X"] keeps the 1.3.x single-file UX — one dropzone, one preview, add-to-cart via WC's native variations_form — because it embeds the product on arbitrary pages where the pvtfw suppression doesn't apply.

Quantity Discount Tiers (per variation)

Each variation can optionally define a Quantity Discount Tiers table in its admin edit panel. Each row is a pair:

  • From Qty — minimum quantity threshold (integer ≥ 1)
  • Discount % — percentage discount (0–100) applied to the entire cart line

The tier with the largest From Qty that is still less than or equal to the line's quantity wins (flat step function). When no row matches (or no tiers are defined), the variation's explicit WooCommerce price is used unchanged.

Rules:

  • Tiers are evaluated per cart line — each line uses its own quantity, no cross-line summing. This matches the typical case where each line has its own uploaded photo.
  • Tiers are stored as post meta _studiou_fpp_qty_tiers on the product_variation post (PHP-serialized array, one sorted list per variation).
  • The feature is gated to FPP-enabled parent products. Non-FPP variable products are not affected.
  • The discounted unit price flows automatically into the cart subtotal, checkout, order line item, emails, invoices, and refunds — no special-casing anywhere else.

Frontend display — designed for the Product Variant Table for WooCommerce (pvtfw) plugin, which is a hard dependency of this plugin:

  • Tier data for all variations of the current product is emitted as an inline <script>window.studiouFppTierData = {…}</script> via wp_footer.
  • A persistent amber "Quantity discount available — save up to X%" notice is rendered above the variants table on any FPP product that has at least one tiered variation (hooked on woocommerce_single_product_summary priority 25).
  • For each row of the <table class="variant"> rendered by pvtfw, the frontend JS:
    • Appends a compact tier summary strip beneath the price cell (e.g. 5+: −5% | 10+: −10% | 50+: −20%).
    • Binds the row's input.qty and .qty-count ± buttons so that changing the quantity updates the row's .woocommerce-Price-amount with the discounted unit price.
    • Shows a green −X% "save" badge next to the price when a tier is active, and reverts the price back to the original HTML when the quantity drops below the smallest tier.
  • The frontend JS also contains a fallback branch that listens for WC's native found_variation / reset_data events on form.variations_form, so the plugin still degrades to sensible behavior if pvtfw is temporarily unavailable.
  • Per-row price updates are display-only. The authoritative pricing happens server-side in woocommerce_before_calculate_totals (priority 20), which calls set_price() on the cart item's product instance. The pvtfw AJAX add-to-cart handler only forwards product_id, variation_id, and quantity — it does not send the displayed price — so there is no risk of client-side tampering.

Add-to-cart is blocked server-side until a photo is uploaded:

  • Validation: woocommerce_add_to_cart_validation (priority 1) calls Studiou_WC_FPP_Single_Product::resolve_uploaded_file() which looks at the WC session first and then falls back to the studiou_fpp_att_id / studiou_fpp_rec_id cookies, verifies the referenced row in wp_studiou_fpp_files matches and is not yet attached to an order, and blocks with an error notice if nothing resolves.
  • Safety net: woocommerce_add_to_cart action removes any FPP cart item that was added without a photo (catches edge cases where validation was bypassed).
  • Product detection uses resolve_fpp_product_id() which checks the product itself, its parent, and the variation's parent to handle all add-to-cart methods.

On page reload, the previously uploaded file is restored — the upload preview and product gallery image are reconstructed from whichever transport currently carries the reference (WC session or the cookie pair). When the photo is removed, both are cleared and the original product image is restored. Add-to-cart buttons always look and behave normally — blocking is entirely server-side.

Shortcode

The shortcode can be used to embed the product with its variation selector and upload area on any page:

[studiou_free_photo_product id="123"]

Replace 123 with your product ID. The shortcode renders:

  • Product image, name, and short description
  • WooCommerce native variation selector and Add to Cart form
  • Upload area with drag-and-drop, progress bar, and preview (injected into the form)

Accepted File Formats

JPEG, PNG, TIFF, BMP, PSD, WebP, and camera RAW formats: CR2 (Canon), NEF (Nikon), ARW (Sony), DNG (Adobe), ORF (Olympus), RW2 (Panasonic), RAF (Fuji).

Chunked Upload

Files are uploaded in 1 MB chunks via AJAX to bypass PHP upload_max_filesize and post_max_size limits. The server assembles chunks into the final file, creates a WordPress media attachment, and assigns it to the configured media category. The chunk size is defined by the STUDIOU_WCFPP_CHUNK_SIZE constant.

Admin Summary Page

Navigate to Products > Free Photo Files to manage uploaded files.

Features

  • Filter by status (Ready / Downloaded / Solved), product, order number (exact match on the numeric order_id), or file-name search
  • Sortable columns: Order, Status, and Date (default sort is Date DESC). Toggle ASC/DESC by clicking the header. Sort and all active filters are preserved across pagination.
  • Inline status change per file via dropdown
  • Single file download (automatically marks status as "downloaded" if currently "ready")
  • Single file delete with media attachment cleanup
  • Bulk actions with confirmation dialogs: Download ZIP, Download as CSV, Set Status (Ready / Downloaded / Solved), Delete
  • The CSV export writes a UTF-8-BOM-prefixed file (Excel-friendly) with columns: ID, File Name, Product, Variation ID, Order, Customer, Status, Date, File URL — mirroring the grid. The file is stored briefly in studiou-fpp-chunks/, served via a nonce-protected URL, and deleted after streaming.
  • File downloads always serve the original, unscaled file. WordPress auto-creates a -scaled.jpg for images above the big-image threshold (2560 px by default), but both single and ZIP downloads use wp_get_original_image_path() to return the raw upload.
  • Order number links (HPOS-compatible)
  • Customer name display
  • Pagination (30 files per page, preserves active filters and the active sort across pages)

File Statuses

Status Meaning
Ready File uploaded, awaiting processing
Downloaded File has been downloaded by admin (set automatically on download)
Solved File has been printed / order fulfilled

Cart and Order Integration

The plugin integrates with WooCommerce's native add-to-cart flow using the resolve_uploaded_file() helper that reads WC session first and then the studiou_fpp_att_id / studiou_fpp_rec_id cookie pair as a fallback. When a photo is uploaded, the file reference is written to both transports in the same request that assembles the file. When any add-to-cart button is clicked — whether it goes through classic admin-ajax or the WC Store API — woocommerce_add_cart_item_data runs the same resolver and attaches the file data to the cart item. Both the session and the cookies persist so the same photo can be reused for multiple variants. The woocommerce_add_to_cart_validation filter uses the same resolver to ensure a photo is uploaded before the item can be added. This approach works with any theme or plugin that handles variable product display (individual variation buttons, dropdowns, tables, Store API block cart, etc.).

When a customer adds a photo product to the cart:

  • The cart item thumbnail shows the uploaded photo — works with both classic cart (woocommerce_cart_item_thumbnail filter) and block cart. The block cart uses woocommerce_get_cart_item_from_session (1.5.4+), which clones the cart line's product instance and set_image_id()s the uploaded attachment onto it — per-line scope, so multiple cart lines sharing a variation_id each render their own uploaded photo.
  • The uploaded file name is shown as "Uploaded Photo" in the cart
  • On checkout, the file record is linked to the order and order item
  • In the order admin, order item thumbnails show the uploaded photo instead of the product image
  • The uploaded photo file name appears as order item metadata
  • A Download uploaded photo button with the original file name is shown below the order item meta in admin (downloading automatically marks the file status as "downloaded")
  • Files linked to orders cannot be removed by the customer

Media Categories

The plugin registers a custom taxonomy studiou_media_category on the attachment post type. Categories are visible in the Media Library admin column and can be managed from:

  • Free Photo product tab (inline add via "+" button)
  • Media Library sidebar

Each product has one assigned media category. All files uploaded for that product are tagged with it.

HPOS Support

The plugin declares full compatibility with WooCommerce High-Performance Order Storage (HPOS / Custom Order Tables). Order edit links in the admin summary page use the correct URL format based on whether HPOS is enabled.

Localization

Supported languages:

  • English (default)
  • Czech (cs_CZ)

Translation files are in the languages/ directory.

To compile the .mo file from .po:

# Using the included helper script
php languages/generate-mo.php

# Or using WP-CLI
wp i18n make-mo languages/

# Or using gettext
msgfmt languages/studiou-wc-free-photo-product-cs_CZ.po -o languages/studiou-wc-free-photo-product-cs_CZ.mo

Text domain: studiou-wc-free-photo-product

Database

The plugin creates one custom table on activation:

{prefix}studiou_fpp_files

Column Type Description
id bigint Primary key
product_id bigint WC product ID
variation_id bigint WC variation ID
order_id bigint WC order ID (set on checkout)
order_item_id bigint WC order item ID (set on checkout)
attachment_id bigint WP media attachment ID
customer_id bigint WP user ID (0 for guests)
session_key varchar(64) WC session key for guest users
batch_token varchar(32) 1.5.0: per-visit opaque token identifying the upload batch
file_name varchar(255) Original uploaded file name
status varchar(20) ready / downloaded / solved
created_at datetime Upload timestamp
updated_at datetime Last update timestamp

File Structure

studiou-wc-free-photo-product/
├── studiou-wc-free-photo-product.php        Main plugin file
├── includes/
│   ├── class-studiou-wc-fpp-db.php          Database + media taxonomy
│   ├── class-studiou-wc-fpp-product.php     Product data tab
│   ├── class-studiou-wc-fpp-upload.php      Chunked upload handler
│   ├── class-studiou-wc-fpp-shortcode.php   Shortcode renderer
│   ├── class-studiou-wc-fpp-single-product.php  Product page upload area
│   ├── class-studiou-wc-fpp-cart.php        Cart/order integration (native WC flow)
│   ├── class-studiou-wc-fpp-admin.php       Admin summary page
│   └── class-studiou-wc-fpp-pricing.php     Per-variation quantity discount tiers
├── views/
│   ├── frontend-product.php                 Shortcode template
│   ├── single-product-upload.php            Upload area for product page
│   └── admin-summary.php                    Admin file list template
├── assets/
│   ├── css/admin.css                        Admin styles
│   ├── css/frontend.css                     Frontend styles
│   ├── js/admin.js                          Admin JS
│   └── js/frontend.js                       Frontend JS (chunked upload)
└── languages/
    ├── studiou-wc-free-photo-product.pot    Translation template
    ├── studiou-wc-free-photo-product-cs_CZ.po  Czech translation
    └── generate-mo.php                      MO file generator

Hooks and Filters

AJAX Actions

Action Auth Description
studiou_wcfpp_upload_chunk Both Upload a file chunk
studiou_wcfpp_remove_upload Both Remove an uploaded file (pre-order)
studiou_wcfpp_add_media_category Admin Create a new media category
studiou_wcfpp_update_status Admin Update single file status
studiou_wcfpp_bulk_status Admin Bulk update file statuses
studiou_wcfpp_delete_files Admin Delete files (single or bulk)
studiou_wcfpp_download_zip Admin Generate ZIP of selected files
studiou_wcfpp_download_csv Admin Export selected file rows to a UTF-8 CSV (Excel-friendly)
studiou_wcfpp_add_file_to_cart Both 1.5.0: per-card Add-to-Cart — adds one (file × variant × qty) as a new cart line
studiou_wcfpp_remove_cart_line Both 1.5.0: remove one cart line from the order-overview panel (cart-only, upload stays)

WooCommerce Hooks Used

Hook Type Description
woocommerce_single_product_summary (priority 35) Action Renders upload area below the variation table
woocommerce_add_to_cart_validation Filter Validates photo is uploaded
woocommerce_add_cart_item_data Filter Captures file data into cart item
woocommerce_cart_item_thumbnail Filter Replaces cart item thumbnail (classic cart)
woocommerce_get_cart_item_from_session Filter Clones the cart line's product and set_image_id()s the uploaded photo onto it — per-line thumbnails for block cart (1.5.4+)
woocommerce_get_item_data Filter Displays file name in cart
woocommerce_checkout_create_order_line_item Action Saves file meta to order item
woocommerce_checkout_order_processed Action Links file record to order (classic checkout)
woocommerce_store_api_checkout_order_processed Action Links file record to order (block checkout)
woocommerce_thankyou Action Fallback linking on thank-you page
woocommerce_admin_order_item_thumbnail Filter Replaces order item thumbnail with uploaded photo
woocommerce_after_order_itemmeta Action Shows download link for uploaded photo in order admin
woocommerce_product_data_tabs Filter Adds Free Photo product tab
woocommerce_product_data_panels Action Renders Free Photo settings panel
woocommerce_process_product_meta Action Saves Free Photo product meta
woocommerce_variation_options_pricing (priority 20) Action Renders the quantity discount tier editor on each variation panel
woocommerce_save_product_variation Action Sanitizes and saves quantity discount tiers per variation
woocommerce_available_variation Filter Injects tier data into the variations form JSON so the frontend JS can render the tier table and live price
woocommerce_before_calculate_totals (priority 20) Action Applies the quantity discount to the cart item's product via set_price()

Product Meta Keys

Key Description
_studiou_fpp_enabled "yes" / "no"
_studiou_fpp_media_category Term ID of media category
_studiou_fpp_max_file_size Max upload size in MB
_studiou_fpp_max_uploads 1.5.0: max simultaneous uploads per visit (0 = unlimited)
_studiou_fpp_min_width Min image width in px (0 = no limit)
_studiou_fpp_min_height Min image height in px (0 = no limit)
_studiou_fpp_max_width Max image width in px (0 = no limit)
_studiou_fpp_max_height Max image height in px (0 = no limit)

Variation Meta Keys

Key Description
_studiou_fpp_qty_tiers Array of {from_qty, percent} rows defining the quantity discount tier table for the variation (sorted ascending by from_qty)

Order Item Meta Keys

Key Description
_studiou_fpp_attachment_id WP attachment ID
_studiou_fpp_file_record_id File record ID in custom table
_studiou_fpp_file_name Original file name
_studiou_fpp_thumb_url Thumbnail URL (for reliable display)

License

GPL v2 or later

Author

Dalibor Votruba - quadarax.com