Conversation
…ent checklist ShippingMethod gains isPickup; when set, checkout skips collecting a shipping address for that method (shippingAddress is now nullable on PrintOrder). The chosen method's isPickup flag is snapshotted onto PrintOrder.isPickupDelivery at order-creation time, same audit-snapshot reasoning as the existing shippingAddress JSON blob. That flag forks the fulfillment state machine after in_production: courier orders keep going through shipped -> delivered, pickup orders go through a new ready_for_pickup -> delivered path instead (shared terminal 'delivered' status, relabeled "picked up" for pickup orders). The allowed-transitions map is now the pure, unit-tested allowedTransitionsFor(status, isPickupDelivery). Studio order-detail page gets a new "Fulfillment" checklist (Ordered / Printing / Shipped or Ready for pickup / Delivered or Picked up), mirroring the existing setup-checklist visual pattern. It's a view onto the real state machine, not an independent toggle — clicking the current step fires the same transition as the equivalent action button, via one shared runTransition() handler, so the two can never drift. Closes markusthiel#37 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
It introduces pickup-specific behavior but leaves at least one verified correctness/contract issue (Zod defaulting on update can reset isPickup, and the pickup flow implies readiness notification without any corresponding trigger).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an in-store pickup fulfillment path to the print shop by introducing a pickup-capable shipping method, snapshotting pickup-ness onto orders, and updating both backend transitions and frontend UI to support a ready_for_pickup → delivered flow (with “delivered” relabeled as “picked up” for pickup orders).
Changes:
- Backend: add
ShippingMethod.isPickup,PrintOrder.isPickupDelivery,PrintOrder.readyForPickupAt, and introduceallowedTransitionsFor(status, isPickupDelivery)plus themark_ready_for_pickuptransition. - Frontend: hide shipping-address fields during checkout for pickup methods; add pickup-aware status labels/badges and a fulfillment checklist in the Studio order detail UI.
- i18n: add new pickup + checklist/status/action strings across EN/DE/FI/IT.
File summaries
| File | Description |
|---|---|
| apps/frontend/src/lib/i18n/it.ts | Adds pickup note, pickup statuses/actions, and fulfillment checklist strings (IT). |
| apps/frontend/src/lib/i18n/fi.ts | Adds pickup note, pickup statuses/actions, and fulfillment checklist strings (FI). |
| apps/frontend/src/lib/i18n/en.ts | Adds pickup note, pickup statuses/actions, and fulfillment checklist strings (EN). |
| apps/frontend/src/lib/i18n/de.ts | Adds pickup note, pickup statuses/actions, and fulfillment checklist strings (DE). |
| apps/frontend/src/lib/api.ts | Updates API client typings for isPickup, isPickupDelivery, nullable shippingAddress, new transition type, and readyForPickupAt. |
| apps/frontend/src/app/studio/print-shop/shipping/page.tsx | Adds “Pickup” badge and checkbox to mark shipping methods as in-store pickup. |
| apps/frontend/src/app/studio/print-shop/orders/page.tsx | Adds ready_for_pickup filter + pickup-aware status badge relabeling. |
| apps/frontend/src/app/studio/print-shop/orders/[id]/page.tsx | Adds shared runTransition(), pickup-aware transitions/labels, nullable shipping address UI, and a fulfillment checklist. |
| apps/frontend/src/app/g/[slug]/print-shop/page.tsx | Hides shipping-address fields and omits shippingAddress in checkout payload for pickup methods. |
| apps/api/src/services/print/orders.ts | Adds isPickupDelivery to pricing, enforces address requirement for non-pickup, adds mark_ready_for_pickup, and exports allowedTransitionsFor. |
| apps/api/src/services/print/orders.test.ts | Adds unit tests for allowedTransitionsFor forked flow. |
| apps/api/src/routes/print-shop.ts | Extends studio routes to accept isPickup, include isPickupDelivery in listings, and allow mark_ready_for_pickup. |
| apps/api/src/routes/print-shop-public.ts | Exposes isPickup in catalog and makes shippingAddress optional for checkout (enforced server-side). |
| apps/api/prisma/schema.prisma | Adds isPickup, isPickupDelivery, readyForPickupAt, and widens shippingAddress to nullable. |
| apps/api/prisma/migrations/20260908100000_print_shop_pickup_delivery/migration.sql | Implements the schema changes in SQL. |
Review details
- Files reviewed: 15/15 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.
| const shippingCreateSchema = z.object({ | ||
| providerKey: z.string().min(1), | ||
| name: z.string().min(1).max(200), | ||
| priceCents: z.number().int().min(0), | ||
| estimatedDaysMin: z.number().int().min(0).nullable().optional(), | ||
| estimatedDaysMax: z.number().int().min(0).nullable().optional(), | ||
| countries: z.array(z.string().length(2).toUpperCase()).default([]), | ||
| providerShippingRef: z.string().max(200).nullable().optional(), | ||
| isPickup: z.boolean().default(false), | ||
| enabled: z.boolean().default(true), | ||
| displayOrder: z.number().int().default(0), | ||
| }); |
| case "mark_ready_for_pickup": | ||
| updates.status = "ready_for_pickup"; | ||
| updates.readyForPickupAt = now; | ||
| break; |
- shippingUpdateSchema derived shippingCreateSchema.partial(), but
four fields (isPickup, enabled, countries, displayOrder) had
.default(...) baked in. Zod applies a field's default whenever the
key is absent, and .partial() doesn't suppress that — so a PUT that
omitted any of them would silently reset it (e.g. re-enabling a
method a studio deliberately disabled, or flipping a pickup method
back to courier). Removed the defaults from the schema; the POST
handler now applies them explicitly instead, so create behavior is
unchanged and update behavior no longer clobbers omitted fields.
- mark_ready_for_pickup updated the order's status/timestamp but never
notified the customer, contradicting the checkout copy's explicit
promise ("we'll let you know as soon as it's ready"). Added
tmplPrintOrderReadyForPickupGuest (mirroring the existing
tmplPrintOrderShippedGuest template, all 4 mail locales) and wired
it into sendOrderMails()/transitionOrder()'s mail-trigger, same
pattern as the existing "shipped" mail.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There are at least two correctness issues in the changed frontend code, including a self-referential TypeScript type in apps/frontend/src/lib/api.ts that should fail type-checking.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 16/16 changed files
- Comments generated: 2
- Review effort level: Lite
| phone?: string; | ||
| }; | ||
| billingAddress?: typeof input.shippingAddress | null; | ||
| billingAddress?: NonNullable<typeof input.shippingAddress> | null; |
| const rank = [ | ||
| "pending_payment", | ||
| "paid", | ||
| "in_production", | ||
| order.isPickupDelivery ? "ready_for_pickup" : "shipped", | ||
| "delivered", | ||
| ]; | ||
| const currentRank = rank.indexOf(order.status); | ||
| if (currentRank < 0) return null; // draft or an unknown status | ||
|
|
||
| return ( | ||
| <Section title={t("orderDetail.secChecklist")}> | ||
| <ul className="space-y-2"> | ||
| {steps.map((s, i) => { | ||
| const stepRank = rank.indexOf(s.status); | ||
| const done = currentRank >= stepRank; |
Summary
ShippingMethod.isPickup(new boolean): studios can now mark a shipping method as in-store pickup. Checkout skips collecting a shipping address for that method (PrintOrder.shippingAddressis now nullable — a widening, additive change).PrintOrder.isPickupDelivery(new boolean) snapshots the chosen method'sisPickupflag at order-creation time — same audit-snapshot reasoning the existingshippingAddressJSON blob already uses, so historical orders stay stable even if a shipping method's config changes later.in_production: courier orders keep going throughshipped → delivered; pickup orders go through a newready_for_pickup → deliveredpath instead. Both converge on the same terminaldeliveredstatus (relabeled "picked up" for pickup orders in the UI) — no need for a whole separate terminal value. The allowed-transitions logic is now the pure, exported, unit-testedallowedTransitionsFor(status, isPickupDelivery)inapps/api/src/services/print/orders.ts, so a courier order can never be markedmark_ready_for_pickupand a pickup order can never be markedmark_shipped.runTransition()handler, so the checklist can never drift from what the order actually did. Cancel/refund stay in a separate action row since they're side-exits, not checklist steps.Closes #37
Test plan
npx tsc --noEmitclean inapps/apiandapps/frontendnpx vitest run— 197/197 tests pass, including 6 new tests forallowedTransitionsFornpm run check:i18nclean (new keys added to all 4 locales)createOrder/transitionOrder/priceCartcalled directly):priceCart()'sisPickupDeliverycorrectly reflects the chosen method (and defaultsfalsewith no method chosen yet, for the price-preview case)mark_shippedrejected atin_production;mark_ready_for_pickupsucceeds, setsreadyForPickupAt, order still reaches shareddeliveredmark_ready_for_pickuprejected atin_production;mark_shipped → mark_deliveredunaffected🤖 Generated with Claude Code