feat(print-shop): select multiple photos at once and add them to the cart in bulk - #40
Conversation
Studios can now bulk-create/update their print catalog from a downloadable JSON template instead of adding every product/variant by hand. Import is upsert-by-SKU (re-uploading an updated file updates matching rows instead of duplicating them) and best-effort: a broken row is skipped with an error in the report, the rest of the file still imports. Preview and commit share one analysis function, so what a studio previews is exactly what gets written. Real-world print price lists almost universally price by quantity break (e.g. 1-19 copies at one rate, 400+ at a much lower one), which the catalog previously couldn't represent — PrintProductVariant only had a single flat price. Added a proper tiered-pricing model: PrintProductVariantPriceTier (additive migration, no backfill), a shared ladder-validation/resolution module used identically by manual variant editing and the importer, and wired the checkout pricing engine (priceCart) to resolve the correct per-unit price from the ordered quantity. The gallery ordering UI (product picker + cart) and the Studio variant editor are now tier-aware; the customer-facing quantity cap moved from 99 to 999 so deep tiers are reachable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…one bad row The bulk import's Zod schemas were re-validating value RANGES (.positive(), .min()) that catalog-import.ts already handles per-row — so a single out-of-range value anywhere in the file (a zero width, a negative tier price, minQty 0) threw a raw ZodError, which the global error handler turns into a 500 for the ENTIRE request, silently defeating the "best-effort, skip only the broken row" design this importer exists for. Loosened the import schemas to type-only checks and let the existing per-row validation in catalog-import.ts do its job; also dropped products.min(1) so an empty file returns a clean zero-row report instead of failing the same way. Also: planVariant() validated negative priceEur but not negative costEur (inconsistent with the manual variant schema, which rejects it) — added the same check. And the Studio tier-row editor had no client-side cap matching the server's 20-tier limit, so a studio could fill in 21+ rows before hitting a submit-time rejection — capped it client-side too. Verified against a real Postgres + live API server: a request that previously 500'd on one bad row (negative width, negative tier price, minQty 0) now returns 200 with the good row created and each bad row individually reported and skipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…elper Provider names (e.g. manual_self_print's "Selbst drucken") come from the API in German and are meant to be localized client-side through useCatalogText()/ct() — the mechanism and dictionary entries already existed (catalogProviderManualSelfPrintLabel etc. in en/it), but the label itself was rendered raw in six places across the print-shop admin UI (providers list x2, product provider select, product list caption, shipping provider select/caption, and the new import page's provider select), while the tagline right next to it already went through ct(). Wired all six up; added the one missing dictionary key (printProducts.providerPrefix) needed by one of them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…le cart resolveUnitPriceForQuantity() was called with each cart line's own quantity, so a customer ordering 15 different photos as one 10x15 print each got priced as fifteen separate 1-copy lines (all landing in the cheapest-quantity tier) instead of one 15-print order of that format. Tiered pricing is meant to reward how many prints of a given FORMAT are ordered in total, not how many copies of one specific photo — a customer printing 15 different photos at 10x15 has ordered 15 units of "10x15", same as if they'd ordered 15 copies of one photo. Fixed by summing quantity per variantId across the whole cart first, then resolving each line's per-unit price from that aggregate — each line's own quantity still only determines its own line total. Extracted the pricing math out of priceCart() into a pure resolveCartItemPricing() so it's directly unit-tested without touching Prisma, matching this codebase's established testing convention. Also fixed the client-side live preview (product picker + cart) in the gallery ordering UI to aggregate the same way, so what a customer sees before checkout matches what priceCart() will actually charge them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Order detail only showed the filename to print, with no way to get the actual file or a structured summary for invoicing. Two additions: - Each order item's filename now links to the existing, already access-controlled GET /files/:id/download endpoint — no new backend download logic needed, just wiring the studio order-detail UI to it. - New GET /print-shop/orders/:id/export.csv: one row per order item (file ID, filename, product, format, dimensions, SKU, quantity, unit price, line total), so a studio doesn't have to retype order details by hand into their invoicing tool. SKU falls back from the variant's own reference to the parent product's when the variant has none. CSV building is a pure, unit-tested function (order-export.ts), mirroring the existing proofing-export CSV pattern (BOM + quoted cells for Excel/Numbers/LibreOffice). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cart in bulk BrowseStep gains a "select multiple" mode (tap to toggle, floating "N selected" bar to confirm) instead of always opening the picker for one photo. PickerDialog generalizes from a single `file` prop to `files: PublicFile[]` — a single click still passes an array of one, so the existing one-photo flow is unchanged — and adding confirms one cart line per selected photo with a shared product/variant/quantity. Crop stays single-photo-only for this pass: batch mode uses each photo's default framing (a crop editor for N differently-shaped photos in one dialog isn't usably designable in a first pass), with a note explaining individual crop needs adding photos one at a time. The tier-price preview and subtotal now account for quantity × number of selected photos, so what's shown matches what the server will actually charge once quantity-break tiers resolve. Closes markusthiel#39 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Client-side tier ladder parsing/validation can silently accept unintended non-integer quantities, and the new tier-aware cart rendering introduces an O(n²) aggregation cost that should be refactored before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds a bulk “select multiple photos” flow to the gallery print-shop UI so users can add many photos to the cart in one picker action, while also incorporating the dependent tiered-pricing + catalog-import groundwork from branch feat/print-catalog-import (PR #34).
Changes:
- Gallery print shop: multi-select in the browse grid + picker dialog updated to accept multiple files and add multiple cart lines in one confirm.
- Pricing: client/server tiered (quantity-break) pricing utilities added and cart pricing updated to resolve tiers based on aggregate per-variant quantity.
- Studio: print catalog bulk import UI/API endpoints and print order CSV export UI/API endpoint added.
File summaries
| File | Description |
|---|---|
| apps/frontend/src/lib/print-pricing.ts | Adds client-side helpers for tier unit-price resolution and per-variant cart quantity aggregation. |
| apps/frontend/src/lib/i18n/en.ts | Adds strings for bulk selection, tier price preview, import, and CSV export. |
| apps/frontend/src/lib/i18n/de.ts | Adds strings for bulk selection, tier price preview, import, and CSV export. |
| apps/frontend/src/lib/i18n/fi.ts | Adds strings for bulk selection, tier price preview, import, and CSV export. |
| apps/frontend/src/lib/i18n/it.ts | Adds strings for bulk selection, tier price preview, import, and CSV export. |
| apps/frontend/src/lib/api.ts | Adds typed endpoints/types for tiered pricing, import, CSV export, and file download URLs. |
| apps/frontend/src/app/studio/print-shop/shipping/page.tsx | Displays provider labels via catalog-i18n (ct) helper. |
| apps/frontend/src/app/studio/print-shop/providers/page.tsx | Displays provider labels via catalog-i18n (ct) helper in lists and dialogs. |
| apps/frontend/src/app/studio/print-shop/products/page.tsx | Adds tiered pricing editor UI, tier ladder validation, and navigation to import page. |
| apps/frontend/src/app/studio/print-shop/orders/[id]/page.tsx | Adds CSV export link and file download links for order items. |
| apps/frontend/src/app/studio/print-shop/import/page.tsx | New bulk catalog import page with upload → preview → commit flow. |
| apps/frontend/src/app/g/[slug]/print-shop/page.tsx | Adds multi-select browse mode, bulk picker flow, tier-aware previews/subtotals, and increases quantity cap to 999. |
| apps/api/src/services/print/pricing-tiers.ts | Implements authoritative tier ladder validation + unit-price resolution on the server. |
| apps/api/src/services/print/pricing-tiers.test.ts | Unit tests for tier ladder validation and unit-price resolution. |
| apps/api/src/services/print/orders.ts | Updates cart pricing to resolve tiers per-variant based on aggregate quantity across cart lines. |
| apps/api/src/services/print/orders.test.ts | Unit tests for per-variant aggregate quantity tier resolution. |
| apps/api/src/services/print/order-export.ts | Adds pure CSV builder for order items export (BOM + quoting). |
| apps/api/src/services/print/order-export.test.ts | Unit tests for CSV export formatting/escaping and edge cases. |
| apps/api/src/services/print/catalog-import.ts | Implements best-effort, upsert-by-SKU catalog import planning + (optional) commit logic. |
| apps/api/src/services/print/catalog-import.test.ts | Unit tests for import planning, EUR→cents conversion, and SKU matching behavior. |
| apps/api/src/routes/print-shop.ts | Adds tier-aware variant CRUD validation, import preview/commit endpoints, and order CSV export endpoint. |
| apps/api/src/routes/print-shop-public.ts | Exposes price tiers in public catalog + raises max cart quantity from 99 to 999. |
| apps/api/prisma/schema.prisma | Adds PrintProductVariantPriceTier model and relation from variants. |
| apps/api/prisma/migrations/20260907100000_print_variant_price_tiers/migration.sql | Adds the new price tiers table, indexes, and FK. |
Review details
- Files reviewed: 24/24 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| {formatPrice( | ||
| fmt, | ||
| // Tier applies per format across the whole cart, not per | ||
| // line — aggregate every line sharing this variant first. | ||
| unitPriceForQuantity( | ||
| it.variant, | ||
| aggregateQuantityForVariant(cart, it.variantId) | ||
| ) * it.quantity, |
| const parsed = rows.map((r) => ({ | ||
| minQty: parseInt(r.minQty, 10), | ||
| maxQty: r.maxQty.trim() === "" ? null : parseInt(r.maxQty, 10), | ||
| unitPriceCents: Math.round(parseFloat(r.priceEuros) * 100), | ||
| })); | ||
|
|
||
| for (const p of parsed) { | ||
| if (!Number.isFinite(p.minQty) || p.minQty < 1) { | ||
| return { ok: false, error: "invalid_min_qty" }; | ||
| } | ||
| if (p.maxQty !== null && (!Number.isFinite(p.maxQty) || p.maxQty < p.minQty)) { | ||
| return { ok: false, error: "invalid_max_qty" }; | ||
| } | ||
| if (!Number.isFinite(p.unitPriceCents) || p.unitPriceCents < 0) { | ||
| return { ok: false, error: "invalid_unit_price" }; | ||
| } | ||
| } |
- Precompute per-variant cart quantities once (buildQuantityByVariantMap) instead of re-aggregating the whole cart inside cart.map() for every line — was O(n^2), flagged as a real render-time cost on PR markusthiel#40/markusthiel#44 and made more likely by the new bulk-select flow (bigger carts). - Collapse three redundant aggregateQuantityForVariant() calls in the picker's per-unit/subtotal preview into one computed value. - Tier ladder quantities (Studio product editor) used parseInt(), which silently truncates non-integer input ("1.5" -> 1) instead of rejecting it, even though the server requires integers. Now uses a strict Number.isInteger() check so a typo doesn't silently submit an unintended ladder. Flagged by Copilot on PR markusthiel#40, markusthiel#42, markusthiel#44 (this code is shared across all three via the branch they're stacked on). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lect # Conflicts: # apps/frontend/src/app/g/[slug]/print-shop/page.tsx
There was a problem hiding this comment.
🔵 Needs a closer look
It combines significant pricing/import/export backend + schema changes with the frontend bulk-order UX, and should be manually validated end-to-end (including performance in large galleries) before merge.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
apps/frontend/src/app/g/[slug]/print-shop/page.tsx:227
inCartis computed by filtering the full cart for every file (files.map×cart.filter), which becomes O(files×cart). With bulk-add, the cart can grow quickly, so this will noticeably slow down rendering large galleries. Precompute a per-file count map once per cart change and reuse it inside the grid loop.
This issue also appears on line 258 of the same file.
apps/frontend/src/app/g/[slug]/print-shop/page.tsx:258
- This per-tile
cart.filter(...)runs once per file rendered; after introducing bulk add, this can be a hot path. Use the precomputedinCartByFileIdmap (computed once above) instead of scanning the cart each time.
const inCart = cart.filter((c) => c.fileId === f.id).length;
- Files reviewed: 24/24 changed files
- Comments generated: 1
- Review effort level: Lite
| /** Client-side mirror of apps/api's validateTierLadder(), for immediate | ||
| * feedback before submit — the server re-validates authoritatively. */ | ||
| /** parseInt() truncates/accepts non-integer input ("1.5" -> 1, silently | ||
| * dropping the fraction). Tier quantities must be whole numbers, so | ||
| * this rejects anything Number.isInteger() wouldn't accept instead of | ||
| * quietly coercing it — NaN propagates to the invalid_min/max_qty | ||
| * checks below the same way a parseInt() failure already did. */ |
|
Small and clean. A few things I specifically liked: Also noticed you picked up One thing I'd change, on the crop. Disabling the crop editor for a multi-selection is right — a crop UI for N differently-framed photos in one dialog isn't usable. But the consequence is that bulk lines go out with
Worth a note in the dialog either way, I think: something saying photos will be cropped to fit the format automatically and can be adjusted by ordering individually. Prints are custom goods and don't come back. |
Summary
BrowseStep(the gallery photo grid inapps/frontend/src/app/g/[slug]/print-shop/page.tsx) gains a "select multiple" mode: tap photos to select them, then a floating "N selected · Add to cart" bar opens the picker scoped to the whole batch at once — instead of repeating the picker flow once per photo.PickerDialoggeneralizes itsfileprop tofiles: PublicFile[](a normal single click still passes an array of one, so the existing one-photo flow is byte-for-byte unchanged). Product/variant/quantity stay one shared choice for the batch; the quantity field becomes "quantity per photo"; confirming creates one cart line per selected photo in a single action.quantity × number of selected photos, matching how the server's quantity-break tiers actually resolve at checkout.Purely a frontend change — no schema/API changes. This branch is based on
feat/print-catalog-import(PR #34, not yet merged) because it reusesaggregateQuantityForVariant/unitPriceForQuantityfromapps/frontend/src/lib/print-pricing.ts, added there. The diff below therefore includes #34's changes until that merges; please review/merge #34 first, or diff againstfeat/print-catalog-importinstead ofmainif reviewing standalone.Closes #39
Test plan
npx tsc --noEmitcleannpm run check:i18nclean (new keys added to all 4 locales)aggregateQuantityForVariant/unitPriceForQuantity), not by clicking through it in a running browser. Flagging that explicitly rather than claiming UI verification I didn't do — worth a manual click-through before merging.🤖 Generated with Claude Code