Skip to content

Checkout

The /checkout surface takes a non-empty cart and turns it into a placed order. The UI is wired to Woo's Store API /checkout endpoint with a WooPayments (Stripe) payment-method handoff — when Stripe keys are set the path is live; when they aren't, submits fall through to a mock-order log so the dev shell keeps building and the UX stays testable.

Route map

Path Purpose
/checkout Main form — auth-aware, redirects empty carts to /shop
/checkout/success?id=… Confirmation page after a successful submit
/checkout/success?id=…&mock=1 Placeholder confirmation (Stripe keys absent)

Both routes are robots: noindex — they're per-user surfaces with no SEO value and are excluded from apps/web/app/sitemap.ts by omission.

Architecture

flowchart TD
  Viewer((Viewer)) -->|GET /checkout| Page[app/checkout/page.tsx]
  Page -->|readCart\(\)| WooProxy[lib/woo-proxy.ts]
  Page -->|readAuthToken + /auth/me| WP[WP fruitplug plugin]
  Page --> Form[CheckoutForm client]
  Form -->|Elements.createPaymentMethod| Stripe((Stripe.js))
  Form -->|stripePaymentMethodId -> form action| Action[createPlaceholderOrder]
  Action -->|validate + re-read cart| WooProxy
  Action -->|placeOrder\(\)| Woo[POST /wc/store/v1/checkout]
  Woo --> WCPay[WooPayments gateway]
  Action -->|redirect_url if 3DS| Stripe3DS[Stripe 3DS hosted page]
  Action -->|on success| Success[/checkout/success]

Server component: app/checkout/page.tsx

  1. readCart() — if the cart is empty we redirect('/shop') before rendering.
  2. readAuthToken() — if present, we call /auth/me and pre-fill the email so the first section collapses to a Continue as … card.
  3. Renders <CheckoutForm /> with the SSR cart + optional viewer.

Client component: components/checkout/CheckoutForm.tsx

Multi-step but single-page — every section is visible at once so the user can fix anything without navigating. Sections:

  1. Contact — email input (skipped when logged in).
  2. Delivery address — first/last name, two address lines, city, UK postcode (regex ^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$), phone, optional instructions. Phone field auto-formats UK numbers as the user types.
  3. Delivery method — radio group sourced from deliveryMethods() in lib/checkout.ts:
  4. UK Next-day — £5.99, default
  5. Saturday morning — £8.99, hidden on Sat/Sun
  6. Local pickup NW10 6EU — free
  7. Payment<StripePaymentElement />. When NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is set, renders Stripe's <PaymentElement /> (cards, Apple Pay, Google Pay, Link). When unset, renders the previous visual placeholder (card number + MM/YY + CVC inputs) so the page still looks "real" during dev.
  8. Discount code — closed accordion, Apply disabled until the coupon endpoint lands.
  9. Place order — full-width magenta button, sticky on mobile.

Payment component: components/checkout/StripePaymentElement.tsx

  • Dynamic-imports @stripe/react-stripe-js so the bundle only pulls it when the publishable key is set (keeps cold shell light for non-payment routes).
  • Exposes a ref.createPaymentMethod() handle — CheckoutForm calls it on submit, receives the paymentMethodId, stashes it on the hidden input stripePaymentMethodId, and re-submits the form.
  • On Stripe load failure or missing key, a placeholder is rendered and the handle returns a well-formed error.

Server action: app/checkout/actions.ts

createPlaceholderOrder(prev, formData) -> { ok: true, orderId } | { ok: false, errors, message? }
placeOrder(form + paymentMethodId)     -> { ok: true, orderId, redirectUrl? } | { ok: false, message }

Two paths, chosen by STRIPE_SECRET_KEY / NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY presence:

  • Stripe off (dev shell) — structured MockOrder logged to stdout, redirect('/checkout/success?id=mock-<ts>&mock=1'). Same shape as before the WooPayments wiring landed.
  • Stripe onplaceOrder() POSTs to WP_STORE_API_URL/checkout with:
{
  "billing_address": { "first_name": "…", "country": "GB", "email": "…" },
  "shipping_address": { "first_name": "…", "country": "GB" },
  "customer_note": "…",
  "payment_method": "woocommerce_payments",
  "payment_data": [
    { "key": "wcpay-payment-method", "value": "pm_1Nxxx…" }
  ]
}

Cart-Token cookie is forwarded (fp_cart or woocommerce_cart_token). Response shape: { order_id, status, payment_result: { payment_status, redirect_url? } }.

  • payment_status === "success" → redirect to /checkout/success?id=<order_id>.
  • payment_result.redirect_url present (3DS / hosted confirm) → redirect the viewer to Stripe; WooPayments flips the order to processing on return.
  • Failure → error bubbles back through useActionState as { ok:false, message }.

payment_method is the WC gateway slug. WooPayments is woocommerce_payments (the default choice). If the team later swaps to the "Stripe Payments" plugin (a different gateway), the slug becomes stripe and the payment_data keys change (stripe_source, stripe_intent). Keep this file in sync with whatever is set in wp-admin → Woo → Settings → Payments.

Validation

Shared between client + server in lib/checkout.ts via validateCheckout(). Client-side runs on every submit to render inline errors. Server-side runs again inside the action so a tampered submit can never succeed.

Go-live checklist

Once the store's WooPayments test keys are available (we need them before /checkout can take a live test card):

  1. Set env vars — in /etc/fruitplug/web.env on A2 (0600 perms) and in .env.local for dev:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_…
STRIPE_SECRET_KEY=sk_test_…
STRIPE_WEBHOOK_SECRET=whsec_…

Publishable keys are safe to ship to the browser (the NEXT_PUBLIC_ prefix opts them in); the secret key MUST stay server-side only.

WooPayments test keys come from wp-admin → Payments → WooPayments → Settings → "Enter sandbox mode". Click the button, copy the pair into the env file, then restart the Next.js container so the new values get baked in (NEXT_PUBLIC_* are build-time substituted).

  1. Enable the WooCommerce Payments plugin on WP — plugin list → activate WooPayments → complete the Stripe Connect onboarding in sandbox mode. Our Store API call fails with "no payment methods enabled" until the gateway is both active AND configured.

  2. Configure the webhook URL — in the WooPayments dashboard, add a webhook pointing at https://fruitplug.co.uk/wp-json/wc/v3/webhooks (or whatever custom handler we land). Store the signing secret as STRIPE_WEBHOOK_SECRET. Until this lands, 3DS-authenticated orders may sit in processing without the "paid" flag flipping.

  3. Smoke test with Stripe test cards/checkout with one item in the cart, use:

Card Scenario
4242 4242 4242 4242 Success, no 3DS
4000 0025 0000 3155 3DS challenge required
4000 0000 0000 9995 Declined (insufficient funds)

Expected: a new order appears in wp-admin → WooCommerce → Orders with status processing, and the browser lands on /checkout/success?id=<id> with the line items rendered.

Fallback behaviour (no Stripe keys)

  • The checkout page still loads.
  • The Payment section shows the visual placeholder card fields + a dashed "Stripe Elements goes here when keys arrive" banner.
  • Submits skip the Store API call, log a MockOrder, and redirect to /checkout/success?id=mock-<ts>&mock=1.
  • The success page prints a yellow "This was a placeholder" banner pointing at this doc.

This keeps the whole pipeline building green in CI without test-key secrets.

Files

File Purpose
apps/web/app/checkout/page.tsx Server component entry
apps/web/app/checkout/actions.ts createPlaceholderOrder + placeOrder server actions
apps/web/app/checkout/success/page.tsx Confirmation page (real + mock branches)
apps/web/components/checkout/CheckoutForm.tsx Client form + sticky CTA
apps/web/components/checkout/StripePaymentElement.tsx Stripe Elements wrapper
apps/web/components/checkout/OrderSummary.tsx Right column summary
apps/web/components/checkout/FieldShell.tsx Shared input wrapper
apps/web/lib/checkout.ts Types, validators, formatters
apps/web/lib/stripe.ts Memoised getStripe() loader