Skip to content

feat(print-shop): in-store pickup delivery option and forked fulfillment checklist - #38

Open
manuzzi wants to merge 2 commits into
markusthiel:mainfrom
manuzzi-photo:feat/print-shop-fulfillment-pickup
Open

manuzzi wants to merge 2 commits into
markusthiel:mainfrom
manuzzi-photo:feat/print-shop-fulfillment-pickup

Conversation

@manuzzi

@manuzzi manuzzi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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.shippingAddress is now nullable — a widening, additive change).
  • PrintOrder.isPickupDelivery (new boolean) snapshots the chosen method's isPickup flag at order-creation time — same audit-snapshot reasoning the existing shippingAddress JSON blob already uses, so historical orders stay stable even if a shipping method's config changes later.
  • 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. Both converge on the same terminal delivered status (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-tested allowedTransitionsFor(status, isPickupDelivery) in apps/api/src/services/print/orders.ts, so a courier order can never be marked mark_ready_for_pickup and a pickup order can never be marked mark_shipped.
  • Studio order-detail page gets a new "Fulfillment" checklist section (Ordered / Printing / Shipped-or-Ready-for-pickup / Delivered-or-Picked-up), visually mirroring the existing print-shop setup checklist. It's a read-at-a-glance view onto the real state machine, not an independent toggle: clicking the current step fires the exact same transition as the equivalent action button, through one shared 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.
  • Checkout UI: when the selected shipping method is a pickup method, the address fields (street/postal/city/country) are hidden in favor of a short note; name/email/phone are still collected.

Closes #37

Test plan

  • npx tsc --noEmit clean in apps/api and apps/frontend
  • npx vitest run — 197/197 tests pass, including 6 new tests for allowedTransitionsFor
  • npm run check:i18n clean (new keys added to all 4 locales)
  • Live-verified against a throwaway Postgres instance (createOrder/transitionOrder/priceCart called directly):
    • courier checkout without a shipping address → rejected; pickup checkout without one → succeeds
    • priceCart()'s isPickupDelivery correctly reflects the chosen method (and defaults false with no method chosen yet, for the price-preview case)
    • pickup order: mark_shipped rejected at in_production; mark_ready_for_pickup succeeds, sets readyForPickupAt, order still reaches shared delivered
    • courier order: mark_ready_for_pickup rejected at in_production; mark_shipped → mark_delivered unaffected

🤖 Generated with Claude Code

…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>
Copilot AI lite review requested due to automatic review settings September 8, 2026 14:07

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

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 introduce allowedTransitionsFor(status, isPickupDelivery) plus the mark_ready_for_pickup transition.
  • 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.

Comment on lines 500 to 511
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),
});
Comment on lines +394 to +397
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>

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

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;
Comment on lines +554 to +569
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;
@markusthiel

Copy link
Copy Markdown
Owner

Now that #34 is on main, this only conflicts on orders.test.ts — the file exists there with the catalog-import cases, so append yours rather than recreating it. Review follows once it's rebased. Details on #42.

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): in-store pickup delivery option and forked fulfillment checklist

3 participants