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¶
readCart()— if the cart is empty weredirect('/shop')before rendering.readAuthToken()— if present, we call/auth/meand pre-fill the email so the first section collapses to aContinue as …card.- 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:
- Contact — email input (skipped when logged in).
- 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. - Delivery method — radio group sourced from
deliveryMethods()inlib/checkout.ts: UK Next-day— £5.99, defaultSaturday morning— £8.99, hidden on Sat/SunLocal pickup NW10 6EU— free- Payment —
<StripePaymentElement />. WhenNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYis 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. - Discount code — closed accordion,
Applydisabled until the coupon endpoint lands. - Place order — full-width magenta button, sticky on mobile.
Payment component: components/checkout/StripePaymentElement.tsx¶
- Dynamic-imports
@stripe/react-stripe-jsso the bundle only pulls it when the publishable key is set (keeps cold shell light for non-payment routes). - Exposes a
ref.createPaymentMethod()handle —CheckoutFormcalls it on submit, receives thepaymentMethodId, stashes it on the hidden inputstripePaymentMethodId, 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
MockOrderlogged to stdout,redirect('/checkout/success?id=mock-<ts>&mock=1'). Same shape as before the WooPayments wiring landed. - Stripe on —
placeOrder()POSTs toWP_STORE_API_URL/checkoutwith:
{
"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_urlpresent (3DS / hosted confirm) → redirect the viewer to Stripe; WooPayments flips the order toprocessingon return.- Failure → error bubbles back through
useActionStateas{ ok:false, message }.
payment_methodis the WC gateway slug. WooPayments iswoocommerce_payments(the default choice). If the team later swaps to the "Stripe Payments" plugin (a different gateway), the slug becomesstripeand thepayment_datakeys change (stripe_source,stripe_intent). Keep this file in sync with whatever is set inwp-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):
- Set env vars — in
/etc/fruitplug/web.envon A2 (0600 perms) and in.env.localfor 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).
-
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.
-
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 asSTRIPE_WEBHOOK_SECRET. Until this lands, 3DS-authenticated orders may sit inprocessingwithout the "paid" flag flipping. -
Smoke test with Stripe test cards —
/checkoutwith 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 |