Skip to content

feat(orders): add branded customer-facing /orders/:id/{paid,cancel} pages#4

Merged
ByteStreams-AI merged 2 commits intomainfrom
feat/order-result-pages
May 2, 2026
Merged

feat(orders): add branded customer-facing /orders/:id/{paid,cancel} pages#4
ByteStreams-AI merged 2 commits intomainfrom
feat/order-result-pages

Conversation

@Bytes0211
Copy link
Copy Markdown
Collaborator

@Bytes0211 Bytes0211 commented May 2, 2026

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 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 is fetched via the new get_restaurant_branding(order_id) RPC (PR #3) which is granted to anon and exposes only safe brand fields.

Changes

Routes (apps/admin/src/app.tsx)

  • Two PUBLIC routes added above the ProtectedRoute wrapper: /orders/:id/paid and /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)

  • Reads :id from the URL, calls supabase.rpc('get_restaurant_branding')
  • Logo block if logo_url set, else the display name styled as a wordmark in primary_color
  • Tagline beneath logo if set
  • Card with check/× icon, headline ("Payment received from {restaurant}"), lede, and (for paid) a "What happens next" panel tinted with secondary_color
  • Loads restaurant font dynamically from Google Fonts at mount, sanitizes input first
  • Falls back to neutral DialTone defaults if the RPC returns null — never show a paying customer a 404

Helpers (apps/admin/src/lib/branding.ts)

  • safeFontFamily(font) — CSS-injection-safe font-family value
  • googleFontHref(font) — Google Fonts CSS2 URL or null
  • Constants for fallbacks
  • 22 unit tests (apps/admin/test/branding.test.ts) covering 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. Override via the secret for staging.

Test plan

  • pnpm ci:fast green (210 unit tests, lint, typecheck)
  • After merge + deploy: 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 (neutral palette, system font, no order-specific copy)
  • Trigger a cancel (Stripe → expire the session) and confirm landing on the cancel page

🤖 Generated with Claude Code

Greptile Summary

This PR fixes a post-Stripe-Checkout 404 by adding two public branded landing pages (/orders/:id/paid and /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, and hexToRgba replaces 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

Filename Overview
apps/admin/src/pages/orders/order-result-page.tsx New public customer-facing Stripe landing page. Branding RPC, font injection, and error handling are well done; noindex meta is placed in the body and won't be honoured by crawlers.
apps/admin/src/lib/branding.ts New branding helpers with good CSS-injection guards. safeFontFamily and googleFontHref use a tight character whitelist; hexToRgba replaces the fragile hex-alpha concatenation correctly.
apps/admin/src/app.tsx Two public routes added above ProtectedRoute wrapper; React Router v6 specificity rules ensure they win over the catch-all correctly.
apps/admin/test/branding.test.ts 22 well-structured unit tests covering null/empty/whitespace/invalid-char inputs and CSS injection attempts for all three branding helpers.
supabase/functions/admin_create_manual_order/index.ts Default base URL updated from dialtone.menu to admin.dialtone.menu to match where the /orders/:id/paid route now lives.
supabase/functions/vapi_finalize_order/index.ts Same base URL default fix as admin_create_manual_order — one-line change, correct and consistent.

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 set
Loading

Reviews (2): Last reviewed commit: "fix(orders): address Greptile P1+P2 — re..." | Re-trigger Greptile

…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>
Comment thread apps/admin/src/pages/orders/order-result-page.tsx
Comment thread apps/admin/src/pages/orders/order-result-page.tsx Outdated
…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>
@Bytes0211
Copy link
Copy Markdown
Collaborator Author

Both Greptile findings addressed in ee59df1.

P1 — eternal spinner. The RPC's .then() had no rejection handler. Switched to two-arg .then(onFulfilled, onRejected):

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 PromiseLike, not a full Promise — no .catch() exposed. Same effect.)

P2 — broken CSS for non-six-digit hex. Replaced the ${secondary}1A string concat with a new hexToRgba(hex, alpha) helper in apps/admin/src/lib/branding.ts:

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 apps/admin/test/branding.test.ts cover six-digit / uppercase / lowercase / mixed-case hex, alpha clamping above 1 and below 0, #RGB shorthand, #RRGGBBAA already-alpha hex, rgb() strings, and malformed input.

Validated: 220 unit tests pass (210 prior + 10 new), lint + typecheck green.

@ByteStreams-AI ByteStreams-AI merged commit f8c4768 into main May 2, 2026
2 checks passed
ByteStreams-AI pushed a commit that referenced this pull request May 2, 2026
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>
@Bytes0211 Bytes0211 deleted the feat/order-result-pages branch May 2, 2026 22:16
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.

2 participants