Skip to content

feat(print-shop): select multiple photos at once and add them to the cart in bulk - #40

Merged
markusthiel merged 8 commits into
markusthiel:mainfrom
manuzzi-photo:feat/print-shop-bulk-select
Sep 16, 2026
Merged

markusthiel merged 8 commits into
markusthiel:mainfrom
manuzzi-photo:feat/print-shop-bulk-select

Conversation

@manuzzi

@manuzzi manuzzi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • BrowseStep (the gallery photo grid in apps/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.
  • PickerDialog generalizes its file prop to files: 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.
  • Crop stays single-photo-only in this pass — batch mode uses each photo's default framing with a note explaining that individual crop needs adding photos one at a time (a crop editor across N differently-shaped photos in one dialog isn't a sensible v1 design).
  • The tier-price preview ("at quantity X: price/pc") and the subtotal shown in the dialog now factor in 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 reuses aggregateQuantityForVariant/unitPriceForQuantity from apps/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 against feat/print-catalog-import instead of main if reviewing standalone.

Closes #39

Test plan

  • npx tsc --noEmit clean
  • npm run check:i18n clean (new keys added to all 4 locales)
  • No frontend test convention exists in this repo for UI components in this area (confirmed against existing test files), and I don't have interactive browser access in this environment — this change was verified via type-checking and careful manual code review (tracing the bulk quantity/price math against the existing, already-tested 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

manuzzi and others added 6 commits September 7, 2026 17:55
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>
Copilot AI lite review requested due to automatic review settings September 8, 2026 14:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +855 to +862
{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,
Comment on lines +757 to +773
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" };
}
}
manuzzi and others added 2 commits September 8, 2026 16:52
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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

  • inCart is 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 precomputed inCartByFileId map (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

Comment on lines +750 to +756
/** 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. */
@markusthiel

Copy link
Copy Markdown
Owner

Small and clean. A few things I specifically liked: toggleSelectMode clearing the selection so you can't leave stale ids behind, files.filter(f => selectedIds.has(f.id)) degrading gracefully if the grid changes underneath, and projectedQuantity correctly becoming quantity * files.length so the tier preview reflects a bulk add. That last one is easy to forget and would have shown customers the wrong price.

Also noticed you picked up shareFilename for the thumbnails and cart names, so bulk orders respect the gallery's label mode. Thanks for reading that code rather than reaching for f.filename.

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 crop: null, while the single-photo path always attaches a crop whenever the variant has a fixed aspectRatio. So the same variant produces order lines in two different shapes, and the studio is the one who has to sort it out: some lines say exactly how the customer wanted it framed, others say nothing at all. With manual_self_print being the working fulfilment path today, that's a person cropping by hand and guessing.

defaultCropForAspect already does the work and takes the photo's own dimensions, so it can just be called per file inside the files.map() instead of passing null — every line then carries a deterministic centered crop, and a customer who wants control over a particular photo orders that one on its own. Same UI, consistent data.

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.

@markusthiel
markusthiel merged commit 4156ba3 into markusthiel:main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(print-shop): select multiple photos at once and add them to the cart in bulk

3 participants