feat(orders): add branded customer-facing /orders/:id/{paid,cancel} pages#4
feat(orders): add branded customer-facing /orders/:id/{paid,cancel} pages#4ByteStreams-AI merged 2 commits intomainfrom
Conversation
…ages
Closes the M8 success_url 404 follow-up. Stripe Checkout was
redirecting paying customers to dialtone.menu/orders/:id/{paid,cancel},
which is the marketing site (no such routes — 404). The order itself
flipped to paid correctly via webhook (server-to-server, independent
of the redirect), but the customer-facing landing was broken.
After deciding the pages must be PER-RESTAURANT branded (a customer
paying for Sui's Sushi shouldn't see DialTone Menu chrome), the
landing pages moved into the admin app where the restaurant data
lives. Branding fetched via the new get_restaurant_branding(order_id)
RPC (PR #3) which is granted to anon and exposes only safe brand
fields.
Routes — apps/admin/src/app.tsx
- Two PUBLIC routes added above the ProtectedRoute wrapper:
/orders/:id/paid — success_url target
/orders/:id/cancel — cancel_url target
Both bypass auth so customers without a session can land on them.
Reusing the OrderResultPage component with kind="paid"|"cancel".
Component — apps/admin/src/pages/orders/order-result-page.tsx
- Reads :id from the URL, calls supabase.rpc('get_restaurant_branding')
- Renders headline + lede that name the restaurant
("Payment received from Sui's Sushi")
- Logo block if logo_url is set, otherwise the display name styled
as a wordmark in primary_color
- Tagline below logo if set
- Card with check/x icon, headline, lede, and (for paid) a
"What happens next" panel tinted with secondary_color
- Loads the restaurant's font dynamically from Google Fonts at
mount, scrubs unsafe characters first
- Falls back to neutral DialTone defaults if the RPC returns null
(we never want to show a paying customer a 404)
Helpers — apps/admin/src/lib/branding.ts (split for unit testability)
- safeFontFamily(font) → CSS-injection-safe font-family value
- googleFontHref(font) → Google Fonts CSS2 stylesheet URL or null
- FALLBACK_PRIMARY / FALLBACK_SECONDARY / SYSTEM_FONT_STACK constants
- 22 unit tests in apps/admin/test/branding.test.ts cover null/empty/
whitespace/invalid-char inputs and CSS-injection attempts
Edge Functions — admin_create_manual_order + vapi_finalize_order
- Default DIALTONE_PUBLIC_BASE_URL changed from 'https://dialtone.menu'
to 'https://admin.dialtone.menu' so the routes resolve out of the
box. DIALTONE_PUBLIC_BASE_URL secret can still override (e.g. for
staging environments).
210 unit tests pass (188 prior + 22 new). Lint + typecheck green.
Test plan after merge:
- Place a fresh manual card order, pay with 4242
- Confirm landing on the new branded paid page (not the 404)
- Confirm Sui's Sushi branding renders (#8B0000 primary, #F5C14A
secondary, Playfair Display font, "Fresh sushi, made to order."
tagline)
- Visit /orders/<unknown-uuid>/paid to confirm fallback rendering
with DialTone defaults
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tint
P1 (eternal spinner): the RPC's `.then()` had no rejection handler.
Supabase v2 normally resolves with {data, error}, but any unhandled
sync throw inside the success handler or a network-layer rejection
would leave `setLoaded` uncalled — trapping a paying customer on a
blank spinner with no recovery. Worst possible UX immediately after
a successful payment. Switched to the two-argument `.then(onFulfilled,
onRejected)` form so we always flip to loaded and render fallback
defaults if anything goes sideways. (Used the two-arg form because
the Supabase query builder is a PromiseLike, not a full Promise — no
`.catch()` available.)
P2 (broken CSS for non-six-digit hex): `${secondary}1A` was building
an `#RRGGBBAA` color by string-concatenating an alpha hex pair onto
the brand color. This worked only when secondary_color was exactly
`#RRGGBB`. The 0007 migration's check constraint enforces that today,
but if the constraint ever loosens or input bypasses it, `#FFC` + `1A`
= `#FFC1A` (5 hex digits — invalid CSS, browser silently drops the
background). Replaced with a new `hexToRgba(hex, alpha)` helper in
`apps/admin/src/lib/branding.ts` that parses #RRGGBB → `rgba(r,g,b,a)`
and falls back to returning the input opaquely for any unexpected
format. 10 new unit tests cover six-digit / mixed-case / shorthand /
malformed / alpha-clamping inputs.
220 unit tests pass (210 prior + 10 new), lint + typecheck green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Both Greptile findings addressed in P1 — eternal spinner. The RPC's void supabase
.rpc('get_restaurant_branding', { p_order_id: orderId })
.then(
(res) => { /* …setLoaded(true) on success */ },
() => { if (!cancelled) setLoaded(true); }, // belt-and-suspenders
);(Used the two-arg form because the Supabase query builder is a P2 — broken CSS for non-six-digit hex. Replaced the hexToRgba('#F5C14A', 0.1) // → 'rgba(245, 193, 74, 0.1)'
hexToRgba('#FFC', 0.1) // → '#FFC' (input unchanged for shorthand — opaque tint > broken CSS)
hexToRgba('rgb(...)', 0.1) // → 'rgb(...)' (input unchanged for non-#RRGGBB formats)10 new unit tests in Validated: 220 unit tests pass (210 prior + 10 new), lint + typecheck green. |
Captures the May 2 work after PRs #2/#3/#4 merged so picking up cold in 2-3 days doesn't require re-discovering anything. README.md - Status bumped from "M1–M7 complete... Next: M8" to "M1–M8 complete, Path A demo proven live" - Stack updated (Telnyx replaces Twilio; Stripe Connect routing now M11 not M7) - "Where things live" table refreshed: planned-migrations now empty, added Edge Function + payment + branding entries, called out that customer-facing post-payment pages live in admin (not the marketing site at dialtone_menu) - Added pointer to developer/m8-live-demo-checklist.md for the deploy runbook + lessons learned AGENTS.md - Current state header rewritten — single paragraph covering today's four PRs and where to look next - Conventions extended with five new bullets agents commonly miss: - Browser-callable Edge Functions need handlePreflight() (M8 lesson) - Auth init must bootstrap from getSession() before subscribing (PR #1) - Per-restaurant branding location + access patterns (RPC vs useAuth-loaded row) (PRs #3 + #4) - Customer-facing /orders/:id/{paid,cancel} live in admin app, not marketing site (PR #4) - Three-Stripe-environment gotcha (M8 lesson) - New "Open follow-ups" section at the bottom with three items — admin chrome branding, kitchen Ready SMS, Path B Vapi — each with scope, estimated effort, and where to start developer/developer-journal.md - New dated entry "May 2 (later)" picking up after the existing May 2 entry (which only covered through Path A demo). Documents PR #3 + PR #4, the dialtone_menu PR #27 reversal, the SQL cleanup of orphaned test orders, and the doc refresh itself - Decisions section: why columns on restaurants over a separate table, why RPC scoped by order_id over slug, why helpers extracted for testability, why hexToRgba falls back to opaque on bad input - Follow-ups section mirrors AGENTS.md "Open follow-ups" — same three items so journal readers + agent readers see the same plan 220 unit tests still pass; lint + typecheck green. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes the M8 success_url 404 follow-up. Stripe Checkout was redirecting paying customers to
dialtone.menu/orders/:id/{paid,cancel}— the marketing site, which has no such routes (404). The order itself flipped topaidcorrectly via webhook (server-to-server, independent of the redirect), but the customer-facing landing was broken.After deciding the pages must be per-restaurant branded (a customer paying for Sui's Sushi shouldn't see DialTone Menu chrome), the landing pages moved into the admin app where the restaurant data lives. Branding is fetched via the new
get_restaurant_branding(order_id)RPC (PR #3) which is granted toanonand exposes only safe brand fields.Changes
Routes (
apps/admin/src/app.tsx)ProtectedRoutewrapper:/orders/:id/paidand/orders/:id/cancel. Both bypass auth so customers without a session can land on them.Component (
apps/admin/src/pages/orders/order-result-page.tsx):idfrom the URL, callssupabase.rpc('get_restaurant_branding')logo_urlset, else the display name styled as a wordmark inprimary_colorpaid) a "What happens next" panel tinted withsecondary_colorHelpers (
apps/admin/src/lib/branding.ts)safeFontFamily(font)— CSS-injection-safefont-familyvaluegoogleFontHref(font)— Google Fonts CSS2 URL or nullapps/admin/test/branding.test.ts) covering null/empty/whitespace/invalid-char inputs and CSS injection attemptsEdge Functions (
admin_create_manual_order,vapi_finalize_order)DIALTONE_PUBLIC_BASE_URLchanged fromhttps://dialtone.menutohttps://admin.dialtone.menuso the routes resolve out of the box. Override via the secret for staging.Test plan
pnpm ci:fastgreen (210 unit tests, lint, typecheck)4242, confirm landing on the new branded paid page (not the 404)#8B0000primary,#F5C14Asecondary, Playfair Display font, "Fresh sushi, made to order." tagline/orders/<unknown-uuid>/paidto confirm fallback rendering with DialTone defaults (neutral palette, system font, no order-specific copy)🤖 Generated with Claude Code
Greptile Summary
This PR fixes a post-Stripe-Checkout 404 by adding two public branded landing pages (
/orders/:id/paidand/orders/:id/cancel) to the admin app, along with sanitised branding helpers and a base-URL default change in the two Edge Functions. The implementation is solid — CSS injection is guarded, the RPC rejection path is handled, andhexToRgbareplaces the brittle hex-alpha concatenation flagged previously.Confidence Score: 5/5
Safe to merge — only a single P2 finding (noindex meta placement) that does not affect correctness or security.
All previously flagged P1 issues (missing rejection handler, broken hex-alpha concatenation) are addressed. The remaining finding is P2 only, which does not affect the confidence ceiling.
apps/admin/src/pages/orders/order-result-page.tsx — noindex meta needs to target
Important Files Changed
Sequence Diagram
sequenceDiagram participant Customer participant Stripe participant AdminApp as admin.dialtone.menu participant Supabase Customer->>Stripe: Completes / cancels checkout Stripe-->>AdminApp: Redirect to /orders/:id/paid (or /cancel) AdminApp->>Supabase: rpc('get_restaurant_branding', { p_order_id }) Supabase-->>AdminApp: Branding row (name, logo, colors, font, tagline) or null AdminApp-->>Customer: Branded landing page (or DialTone defaults if null) Note over AdminApp: Font loaded dynamically<br/>from Google Fonts if setReviews (2): Last reviewed commit: "fix(orders): address Greptile P1+P2 — re..." | Re-trigger Greptile