# 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 select a product variant using WooCommerce's native variation selector and upload their photo file, which gets stored in the WordPress media library under a configurable media category. ## 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`) — used to render variants as a table with per-row quantity inputs. 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. 8. Copy the displayed **Shortcode** for use on custom pages ### Product Detail Page When a variable product has Free Photo enabled, the upload area automatically appears on the WooCommerce single product page **below** the product summary (below the variation table and Add to Cart buttons). The customer workflow is: 1. Upload a photo file via drag-and-drop or file browser (below the variation table) 2. The product gallery image updates to show the uploaded photo preview 3. Select a variant from the product variation table and click its **Add to Cart** button 4. If no photo is uploaded, the server blocks the add-to-cart and shows an error notice 5. The uploaded photo stays — the same photo can be used for multiple variants without re-uploading The uploaded file reference is stored in two parallel locations — the WooCommerce session **and** a pair of first-party HttpOnly cookies (`studiou_fpp_att_id` / `studiou_fpp_rec_id`) written server-side on the same request that assembles the file. The cookie pair is required because Product Variant Table for WooCommerce 1.9.3+ and WooCommerce 10.7.0+ route Add to Cart through the WC **Store API**, which identifies its session via the `Cart-Token` request header rather than the classic `wp_woocommerce_session_*` cookie — and that path sees a different, empty session than the one the upload wrote to. The cookies travel with *every* same-origin request (classic admin-ajax **and** Store API fetch calls), so whichever endpoint PVT / WC decide to use, the upload reference is reachable. Both the session and the cookies persist across add-to-cart actions, allowing the same photo to be used for multiple variants without re-uploading; both are cleared when the user explicitly removes the uploaded photo. ### 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 `` 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 `` 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, or file name search - 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 as ZIP, Set Status (Ready / Downloaded / Solved), Delete - 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 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** (`woocommerce_product_get_image_id` / `woocommerce_product_variation_get_image_id` filters that override the product image for the Store API) - 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`: ```bash # 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 | | 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_set_upload_session` | Both | Store uploaded file reference in WC session | | `studiou_wcfpp_clear_upload_session` | Both | Clear uploaded file reference from WC session | | `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 | ### 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_product_get_image_id` | Filter | Returns uploaded photo as product image (block cart) | | `woocommerce_product_variation_get_image_id` | Filter | Returns uploaded photo as variation image (block cart) | | `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_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](https://www.quadarax.com)