create-ailk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +210 -52
- package/dist/cli.js +0 -0
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +14 -15
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +35 -4
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +602 -29
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1278
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,532 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ResultsClient — the results permalink page's composition + view state
|
|
5
|
-
* machine (#4842): `results` → `checkout` | `save` → saved.
|
|
6
|
-
*
|
|
7
|
-
* Runs the already-shipped pure `recommend()` on the decoded answers and
|
|
8
|
-
* composes the LOCKED two-region frame (`FlowFrame`, DESIGN §2/§3/§7.2):
|
|
9
|
-
*
|
|
10
|
-
* - **results** — side panel = `FlowSidePanel` `safe-default` (the top fit,
|
|
11
|
-
* its shown price when the register can price it, the always-present
|
|
12
|
-
* Book-a-Call rail, §7.3); active region = the fits as
|
|
13
|
-
* `RecommendationCard` (first fit `emphasis: "recommended"`, each with a
|
|
14
|
-
* per-offer terminal — D2) + `PairingCard` when one matched; bar
|
|
15
|
-
* `leading` = "Email me this plan" (D5).
|
|
16
|
-
* - **checkout** — Get Started performs DESIGN §7.6's panel FLIP: the
|
|
17
|
-
* email step takes the frame's first (side) slot — "fields left" — and
|
|
18
|
-
* the configurable `CheckoutPriceCard` (#4865, D3) takes the second
|
|
19
|
-
* (active) slot — "price card right"; the card is controlled here, so its
|
|
20
|
-
* seat / period / credit-stop state is EXACTLY what Pay sends. Below `md`
|
|
21
|
-
* the frame stacks its slots in that same order, so the email step is
|
|
22
|
-
* first on mobile and the card is the scroll-to. Bar `leading` = Back,
|
|
23
|
-
* `trailing` = Pay.
|
|
24
|
-
* - **save** — the same email step with the save framing; the side panel
|
|
25
|
-
* stays the safe-default card (FR4); success renders the saved state.
|
|
26
|
-
*
|
|
27
|
-
* The three frame regions keep their DOM identity across every transition
|
|
28
|
-
* (#4806 FR7 — the frame is rendered once; only its slots change).
|
|
29
|
-
*
|
|
30
|
-
* Before a terminal or the save CTA is selected AND a valid email submitted,
|
|
31
|
-
* this component makes no request of any kind (FR5 / PRD L108): the only
|
|
32
|
-
* network call on the page is the server component's `flows.get`.
|
|
33
|
-
*
|
|
34
|
-
* `RecommendRegister` (D6, #4770) is a structural `Pick` of the five fields
|
|
35
|
-
* `recommend()` / `price()` read — `key`, `offers`, `pairings`, `rateTable`,
|
|
36
|
-
* `scoring` — so a `FlowConfig` needs only a `key` to satisfy it; the
|
|
37
|
-
* compiled config carries the register's own key on `experiment.key`.
|
|
38
|
-
*/
|
|
39
|
-
import type {
|
|
40
|
-
CheckoutConfiguration,
|
|
41
|
-
CheckoutPriceCardConfiguratorLabels,
|
|
42
|
-
CheckoutPriceCardLabels,
|
|
43
|
-
FlowSidePanelView,
|
|
44
|
-
RecommendationCardPrice,
|
|
45
|
-
RecommendationCardTerminal,
|
|
46
|
-
} from "@working-theory/ui";
|
|
47
|
-
import {
|
|
48
|
-
Button,
|
|
49
|
-
CheckoutPriceCard,
|
|
50
|
-
FlowFrame,
|
|
51
|
-
FlowSidePanel,
|
|
52
|
-
Heading,
|
|
53
|
-
PairingCard,
|
|
54
|
-
RecommendationCard,
|
|
55
|
-
Text,
|
|
56
|
-
} from "@working-theory/ui";
|
|
57
|
-
import type {
|
|
58
|
-
FlowConfig,
|
|
59
|
-
FlowSaveInput,
|
|
60
|
-
RecommendRegister,
|
|
61
|
-
WaitlistBillingPeriod,
|
|
62
|
-
} from "@working-theory/validation";
|
|
63
|
-
import { recommend, resolveLocalizedString } from "@working-theory/validation";
|
|
64
|
-
import { useTranslations } from "next-intl";
|
|
65
|
-
import type { ReactElement, ReactNode } from "react";
|
|
66
|
-
import { useEffect, useMemo, useState } from "react";
|
|
67
|
-
|
|
68
|
-
import type { StartCheckoutInput } from "../../checkout.actions";
|
|
69
|
-
import {
|
|
70
|
-
saveRecommendationAction,
|
|
71
|
-
startCheckoutAction,
|
|
72
|
-
} from "../../checkout.actions";
|
|
73
|
-
|
|
74
|
-
import { ResultsEmailStep } from "./ResultsEmailStep";
|
|
75
|
-
|
|
76
|
-
export type ResultsReturnState = "success" | "cancelled";
|
|
77
|
-
|
|
78
|
-
interface Props {
|
|
79
|
-
config: FlowConfig;
|
|
80
|
-
locale: string;
|
|
81
|
-
answers: Record<string, string | string[]>;
|
|
82
|
-
slug: string;
|
|
83
|
-
/** The encoded permalink payload — the return URLs are built from it. */
|
|
84
|
-
payload: string;
|
|
85
|
-
/** FR7/D6 — the `?checkout=` return micro-state, validated by the page. */
|
|
86
|
-
returnState?: ResultsReturnState;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
type View = "results" | "checkout" | "save";
|
|
90
|
-
|
|
91
|
-
const EMAIL_FORM_ID = "flow-results-email";
|
|
92
|
-
|
|
93
|
-
// Mirrors `FlowStepperClient.tsx` (#4707) — every path this component builds
|
|
94
|
-
// passes its locale through the same build-time-baked allowlist, never the
|
|
95
|
-
// raw route segment.
|
|
96
|
-
const DEFAULT_LOCALE = "en";
|
|
97
|
-
const ENABLED_LOCALES = (
|
|
98
|
-
process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE
|
|
99
|
-
).split(",");
|
|
100
|
-
|
|
101
|
-
/** Validate a candidate locale against the configured set; unknown → default. */
|
|
102
|
-
function resolveSafeLocale(candidate: string): string {
|
|
103
|
-
return ENABLED_LOCALES.includes(candidate) ? candidate : DEFAULT_LOCALE;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Next's redirect control-flow error (`redirect()` inside a Server Action),
|
|
108
|
-
* recognised by its digest — `NEXT_REDIRECT;<push|replace>;<url>;<status>;`
|
|
109
|
-
* (`next/dist/client/components/redirect-error.js`). Never a checkout failure.
|
|
110
|
-
*/
|
|
111
|
-
function isNextRedirect(err: unknown): boolean {
|
|
112
|
-
return (
|
|
113
|
-
typeof err === "object" &&
|
|
114
|
-
err !== null &&
|
|
115
|
-
"digest" in err &&
|
|
116
|
-
typeof (err as { digest: unknown }).digest === "string" &&
|
|
117
|
-
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT;")
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** D4 — the one `sessionStorage` key `FlowStepperClient` writes. */
|
|
122
|
-
function flowSessionStorageKey(slug: string): string {
|
|
123
|
-
return `ailk:flow-session:${slug}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** D2 — an offer is quotable iff the register names a currency AND a seat rate for it. */
|
|
127
|
-
function firstSeatRatePeriod(
|
|
128
|
-
register: RecommendRegister,
|
|
129
|
-
offerKey: string,
|
|
130
|
-
): WaitlistBillingPeriod | undefined {
|
|
131
|
-
const rateTable = register.rateTable;
|
|
132
|
-
if (rateTable?.currency === undefined) return undefined;
|
|
133
|
-
return rateTable.seatRates.find((rate) => rate.offer === offerKey)?.period;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function ResultsClient({
|
|
137
|
-
config,
|
|
138
|
-
locale,
|
|
139
|
-
answers,
|
|
140
|
-
slug,
|
|
141
|
-
payload,
|
|
142
|
-
returnState,
|
|
143
|
-
}: Props): ReactElement {
|
|
144
|
-
const t = useTranslations("flow");
|
|
145
|
-
const { defaultLocale, offers = [], recommendation } = config;
|
|
146
|
-
|
|
147
|
-
const register = useMemo<RecommendRegister>(
|
|
148
|
-
() => ({
|
|
149
|
-
key: config.experiment?.key ?? config.slug,
|
|
150
|
-
offers: config.offers,
|
|
151
|
-
pairings: config.pairings,
|
|
152
|
-
rateTable: config.rateTable,
|
|
153
|
-
scoring: config.scoring,
|
|
154
|
-
}),
|
|
155
|
-
[config],
|
|
156
|
-
);
|
|
157
|
-
const result = useMemo(() => recommend(register, answers), [register, answers]);
|
|
158
|
-
const currency = config.rateTable?.currency;
|
|
159
|
-
|
|
160
|
-
const [view, setView] = useState<View>("results");
|
|
161
|
-
const [selectedOfferKey, setSelectedOfferKey] = useState<string | undefined>();
|
|
162
|
-
const [configuration, setConfiguration] = useState<CheckoutConfiguration>({
|
|
163
|
-
seats: 1,
|
|
164
|
-
period: "annual",
|
|
165
|
-
creditStop: { kind: "none" },
|
|
166
|
-
});
|
|
167
|
-
const [breakdownOpen, setBreakdownOpen] = useState(false);
|
|
168
|
-
const [sessionId, setSessionId] = useState<string | undefined>();
|
|
169
|
-
const [pending, setPending] = useState(false);
|
|
170
|
-
const [error, setError] = useState<string | undefined>();
|
|
171
|
-
const [saved, setSaved] = useState(false);
|
|
172
|
-
|
|
173
|
-
// D4 — read the walk's own session id; a privacy mode that throws, or no
|
|
174
|
-
// entry at all, degrades to cold (the key is simply omitted from the body).
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
try {
|
|
177
|
-
const stored = window.sessionStorage.getItem(flowSessionStorageKey(slug));
|
|
178
|
-
if (stored) setSessionId(stored);
|
|
179
|
-
} catch {
|
|
180
|
-
// cold load
|
|
181
|
-
}
|
|
182
|
-
}, [slug]);
|
|
183
|
-
|
|
184
|
-
const resolve = (value: Parameters<typeof resolveLocalizedString>[0]): string =>
|
|
185
|
-
resolveLocalizedString(value, locale, defaultLocale);
|
|
186
|
-
|
|
187
|
-
const safeLocale = resolveSafeLocale(locale);
|
|
188
|
-
const bookCallHref = `/${safeLocale}/schedule`;
|
|
189
|
-
|
|
190
|
-
const periodLabels: Record<WaitlistBillingPeriod, string> = {
|
|
191
|
-
monthly: t("priceCard.periodLabels.monthly"),
|
|
192
|
-
annual: t("priceCard.periodLabels.annual"),
|
|
193
|
-
"one-time": t("priceCard.periodLabels.one-time"),
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
const priceCardLabels: CheckoutPriceCardLabels = {
|
|
197
|
-
eyebrow: t("priceCard.eyebrow"),
|
|
198
|
-
seatRowLabel: t("priceCard.seatRowLabel"),
|
|
199
|
-
creditRowLabel: t("priceCard.creditRowLabel"),
|
|
200
|
-
totalLabel: t("priceCard.totalLabel"),
|
|
201
|
-
dueTodayLabel: t("priceCard.dueTodayLabel"),
|
|
202
|
-
periodLabels,
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const configuratorLabels: CheckoutPriceCardConfiguratorLabels = {
|
|
206
|
-
seatsLabel: t("priceCard.seatsLabel"),
|
|
207
|
-
decrementLabel: t("priceCard.decrementLabel"),
|
|
208
|
-
incrementLabel: t("priceCard.incrementLabel"),
|
|
209
|
-
periodLabel: t("priceCard.periodLabel"),
|
|
210
|
-
creditsLabel: t("priceCard.creditsLabel"),
|
|
211
|
-
creditNoneLabel: t("priceCard.creditNoneLabel"),
|
|
212
|
-
creditTierLabel: (credits) => t("priceCard.creditTierLabel", { credits }),
|
|
213
|
-
showBreakdownLabel: t("priceCard.showBreakdownLabel"),
|
|
214
|
-
hideBreakdownLabel: t("priceCard.hideBreakdownLabel"),
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
const topFit = result.fits[0];
|
|
218
|
-
const topOffer = topFit
|
|
219
|
-
? offers.find((offer) => offer.key === topFit.offerKey)
|
|
220
|
-
: undefined;
|
|
221
|
-
const selectedOffer = selectedOfferKey
|
|
222
|
-
? offers.find((offer) => offer.key === selectedOfferKey)
|
|
223
|
-
: undefined;
|
|
224
|
-
|
|
225
|
-
function startCheckout(offerKey: string): void {
|
|
226
|
-
const period = firstSeatRatePeriod(register, offerKey);
|
|
227
|
-
if (period === undefined) return; // D2 — never quotable → never reaches here
|
|
228
|
-
setSelectedOfferKey(offerKey);
|
|
229
|
-
setConfiguration({ seats: 1, period, creditStop: { kind: "none" } });
|
|
230
|
-
setBreakdownOpen(false);
|
|
231
|
-
setError(undefined);
|
|
232
|
-
setView("checkout");
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function openSave(): void {
|
|
236
|
-
setError(undefined);
|
|
237
|
-
setSaved(false);
|
|
238
|
-
setView("save");
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function back(): void {
|
|
242
|
-
setError(undefined);
|
|
243
|
-
setView("results");
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function failureMessage(
|
|
247
|
-
kind: string,
|
|
248
|
-
retryAfterSeconds: number | undefined,
|
|
249
|
-
fallbackKey: "checkoutError" | "submitError",
|
|
250
|
-
): string {
|
|
251
|
-
if (kind === "rate_limited" && retryAfterSeconds !== undefined) {
|
|
252
|
-
return t("rateLimited", { seconds: retryAfterSeconds });
|
|
253
|
-
}
|
|
254
|
-
return t(fallbackKey);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
async function handlePay(email: string): Promise<void> {
|
|
258
|
-
if (selectedOfferKey === undefined) return;
|
|
259
|
-
setPending(true);
|
|
260
|
-
setError(undefined);
|
|
261
|
-
// The Stripe return URLs are built by the action from the app's own
|
|
262
|
-
// origin + this permalink (security review, #4875) — never sent from here.
|
|
263
|
-
const body: StartCheckoutInput = {
|
|
264
|
-
answers,
|
|
265
|
-
email,
|
|
266
|
-
locale,
|
|
267
|
-
offerKey: selectedOfferKey,
|
|
268
|
-
seats: configuration.seats,
|
|
269
|
-
period: configuration.period,
|
|
270
|
-
creditStop: configuration.creditStop,
|
|
271
|
-
payload,
|
|
272
|
-
...(sessionId !== undefined ? { sessionId } : {}),
|
|
273
|
-
};
|
|
274
|
-
try {
|
|
275
|
-
const outcome = await startCheckoutAction(slug, body);
|
|
276
|
-
// On success the action `redirect()`s — a returned value is always a
|
|
277
|
-
// failure.
|
|
278
|
-
if (outcome) {
|
|
279
|
-
setError(failureMessage(outcome.kind, outcome.retryAfterSeconds, "checkoutError"));
|
|
280
|
-
setPending(false);
|
|
281
|
-
}
|
|
282
|
-
} catch (err) {
|
|
283
|
-
// A NEXT_REDIRECT is the SUCCESS path — Stripe's hosted page. It is
|
|
284
|
-
// Next's control-flow error, never a checkout failure: the router is
|
|
285
|
-
// already navigating (the action response carried the redirect), so it
|
|
286
|
-
// is left alone — `pending` stays set and no error renders. Rethrowing
|
|
287
|
-
// from an event handler would reach no framework boundary; it would
|
|
288
|
-
// only be an unhandled rejection. Only a genuine transport failure
|
|
289
|
-
// lands in the error micro-state below.
|
|
290
|
-
if (isNextRedirect(err)) return;
|
|
291
|
-
setError(t("checkoutError"));
|
|
292
|
-
setPending(false);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async function handleSave(email: string): Promise<void> {
|
|
297
|
-
setPending(true);
|
|
298
|
-
setError(undefined);
|
|
299
|
-
const body: FlowSaveInput = {
|
|
300
|
-
answers,
|
|
301
|
-
email,
|
|
302
|
-
locale,
|
|
303
|
-
...(sessionId !== undefined ? { sessionId } : {}),
|
|
304
|
-
};
|
|
305
|
-
try {
|
|
306
|
-
const outcome = await saveRecommendationAction(slug, body);
|
|
307
|
-
if (outcome.ok) {
|
|
308
|
-
setSaved(true);
|
|
309
|
-
} else {
|
|
310
|
-
setError(failureMessage(outcome.kind, outcome.retryAfterSeconds, "submitError"));
|
|
311
|
-
}
|
|
312
|
-
} catch {
|
|
313
|
-
setError(t("submitError"));
|
|
314
|
-
} finally {
|
|
315
|
-
setPending(false);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ── Side panel ──────────────────────────────────────────────────────────
|
|
320
|
-
|
|
321
|
-
const safeDefaultView: FlowSidePanelView | undefined = topOffer
|
|
322
|
-
? {
|
|
323
|
-
kind: "safe-default",
|
|
324
|
-
anchorTitle: t("sidePanel.anchorTitle"),
|
|
325
|
-
offerName: resolve(topOffer.copy.name),
|
|
326
|
-
...(currency !== undefined && topOffer.price.kind !== "label"
|
|
327
|
-
? {
|
|
328
|
-
price: {
|
|
329
|
-
amountMinor: Math.round(topOffer.price.amount * 100),
|
|
330
|
-
currency,
|
|
331
|
-
locale,
|
|
332
|
-
},
|
|
333
|
-
showPrice: true,
|
|
334
|
-
}
|
|
335
|
-
: {}),
|
|
336
|
-
action: { kind: "link", href: bookCallHref, label: t("bookCall") },
|
|
337
|
-
}
|
|
338
|
-
: undefined;
|
|
339
|
-
|
|
340
|
-
// The frame's regions carry no inner padding of their own (FlowFrame.tsx
|
|
341
|
-
// :151/:159) — the consumer pads, exactly as FlowStepper does for its step
|
|
342
|
-
// body (`mx-auto w-full max-w-container-sm p-card`, FlowStepper.tsx:1280).
|
|
343
|
-
const REGION_CLASS = "mx-auto w-full max-w-container-sm p-card";
|
|
344
|
-
|
|
345
|
-
const safeDefaultPanel: ReactNode = safeDefaultView ? (
|
|
346
|
-
<div className={REGION_CLASS}>
|
|
347
|
-
<FlowSidePanel view={safeDefaultView} />
|
|
348
|
-
</div>
|
|
349
|
-
) : undefined;
|
|
350
|
-
|
|
351
|
-
// DESIGN §7.6 — the configurable price card, rendered into the frame's
|
|
352
|
-
// SECOND slot (right at md+) on the checkout view; the email step takes the
|
|
353
|
-
// first slot. Composed once here, placed below.
|
|
354
|
-
let priceCard: ReactNode;
|
|
355
|
-
if (view === "checkout" && selectedOffer && currency !== undefined) {
|
|
356
|
-
priceCard = (
|
|
357
|
-
<div className={REGION_CLASS}>
|
|
358
|
-
<CheckoutPriceCard
|
|
359
|
-
offer={selectedOffer}
|
|
360
|
-
currency={currency}
|
|
361
|
-
locale={locale}
|
|
362
|
-
{...(defaultLocale !== undefined ? { defaultLocale } : {})}
|
|
363
|
-
labels={priceCardLabels}
|
|
364
|
-
configurator={{
|
|
365
|
-
register,
|
|
366
|
-
value: configuration,
|
|
367
|
-
onChange: setConfiguration,
|
|
368
|
-
breakdownOpen,
|
|
369
|
-
onBreakdownOpenChange: setBreakdownOpen,
|
|
370
|
-
labels: configuratorLabels,
|
|
371
|
-
}}
|
|
372
|
-
data-testid="checkout-price-card"
|
|
373
|
-
/>
|
|
374
|
-
</div>
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ── Slots ────────────────────────────────────────────────────────────────
|
|
379
|
-
//
|
|
380
|
-
// `sidePanel` is the frame's FIRST slot (left at md+, first in the mobile
|
|
381
|
-
// stack); `active` is its SECOND. On the results and save views the safe-
|
|
382
|
-
// default card is first and the recommendation / email step second; on the
|
|
383
|
-
// checkout view the slots FLIP (DESIGN §7.6 / §2's Checkout row): the email
|
|
384
|
-
// step is first and the price card second.
|
|
385
|
-
|
|
386
|
-
let sidePanel: ReactNode = safeDefaultPanel;
|
|
387
|
-
let active: ReactNode;
|
|
388
|
-
let leading: ReactNode;
|
|
389
|
-
let trailing: ReactNode;
|
|
390
|
-
|
|
391
|
-
if (view === "results") {
|
|
392
|
-
const heading = recommendation ? resolve(recommendation.copy.title) : undefined;
|
|
393
|
-
const body = recommendation?.copy.body ? resolve(recommendation.copy.body) : undefined;
|
|
394
|
-
|
|
395
|
-
active = (
|
|
396
|
-
<div data-testid="flow-results">
|
|
397
|
-
{returnState !== undefined ? (
|
|
398
|
-
<div
|
|
399
|
-
role="status"
|
|
400
|
-
data-testid="flow-results-return-state"
|
|
401
|
-
className="mb-block-loose"
|
|
402
|
-
>
|
|
403
|
-
<Text variant="body-sm" tone="muted">
|
|
404
|
-
{t(returnState === "success" ? "checkoutSuccess" : "checkoutCancelled")}
|
|
405
|
-
</Text>
|
|
406
|
-
</div>
|
|
407
|
-
) : null}
|
|
408
|
-
{heading ? <Heading level={1}>{heading}</Heading> : null}
|
|
409
|
-
{body ? (
|
|
410
|
-
<Text variant="body" tone="muted" className="mt-block-tight">
|
|
411
|
-
{body}
|
|
412
|
-
</Text>
|
|
413
|
-
) : null}
|
|
414
|
-
<div className="mt-block-loose flex flex-col gap-4">
|
|
415
|
-
{result.fits.map((fit, index) => {
|
|
416
|
-
const offer = offers.find((o) => o.key === fit.offerKey);
|
|
417
|
-
if (!offer) return null;
|
|
418
|
-
const quotable = firstSeatRatePeriod(register, offer.key) !== undefined;
|
|
419
|
-
const terminal: RecommendationCardTerminal = quotable
|
|
420
|
-
? {
|
|
421
|
-
kind: "getStarted",
|
|
422
|
-
label: t("getStarted"),
|
|
423
|
-
onSelect: () => startCheckout(offer.key),
|
|
424
|
-
}
|
|
425
|
-
: {
|
|
426
|
-
kind: "bookCall",
|
|
427
|
-
label: t("bookCall"),
|
|
428
|
-
onSelect: () => window.location.assign(bookCallHref),
|
|
429
|
-
};
|
|
430
|
-
const price: RecommendationCardPrice | undefined =
|
|
431
|
-
currency !== undefined
|
|
432
|
-
? {
|
|
433
|
-
...offer.price,
|
|
434
|
-
currency,
|
|
435
|
-
locale,
|
|
436
|
-
...(defaultLocale !== undefined ? { defaultLocale } : {}),
|
|
437
|
-
periodLabels,
|
|
438
|
-
fromPrefix: t("price.fromPrefix"),
|
|
439
|
-
}
|
|
440
|
-
: undefined;
|
|
441
|
-
return (
|
|
442
|
-
<RecommendationCard
|
|
443
|
-
key={fit.offerKey}
|
|
444
|
-
data-testid={`recommendation-card-${fit.offerKey}`}
|
|
445
|
-
title={resolve(offer.copy.name)}
|
|
446
|
-
{...(offer.copy.description
|
|
447
|
-
? { description: resolve(offer.copy.description) }
|
|
448
|
-
: {})}
|
|
449
|
-
emphasis={index === 0 ? "recommended" : "default"}
|
|
450
|
-
{...(index === 0 ? { recommendedLabel: t("priceCard.eyebrow") } : {})}
|
|
451
|
-
{...(price !== undefined ? { price } : {})}
|
|
452
|
-
terminal={terminal}
|
|
453
|
-
whyThis={{ label: t("whyThis"), content: resolve(fit.reason) }}
|
|
454
|
-
/>
|
|
455
|
-
);
|
|
456
|
-
})}
|
|
457
|
-
{result.pairing ? (
|
|
458
|
-
<PairingCard
|
|
459
|
-
title={t("pairingTitle")}
|
|
460
|
-
pairing={result.pairing}
|
|
461
|
-
offers={offers}
|
|
462
|
-
locale={locale}
|
|
463
|
-
{...(defaultLocale !== undefined ? { defaultLocale } : {})}
|
|
464
|
-
data-testid="recommendation-pairing"
|
|
465
|
-
/>
|
|
466
|
-
) : null}
|
|
467
|
-
</div>
|
|
468
|
-
</div>
|
|
469
|
-
);
|
|
470
|
-
leading = (
|
|
471
|
-
<Button type="button" variant="ghost" size="big" onClick={openSave}>
|
|
472
|
-
{t("emailMePlan")}
|
|
473
|
-
</Button>
|
|
474
|
-
);
|
|
475
|
-
trailing = undefined;
|
|
476
|
-
} else {
|
|
477
|
-
const isCheckout = view === "checkout";
|
|
478
|
-
const emailStep = (
|
|
479
|
-
<ResultsEmailStep
|
|
480
|
-
title={t(isCheckout ? "emailStepTitle" : "emailStepSaveTitle")}
|
|
481
|
-
formId={EMAIL_FORM_ID}
|
|
482
|
-
labels={{
|
|
483
|
-
invalidEmail: t("validationInvalidEmail"),
|
|
484
|
-
placeholder: t("emailPlaceholder"),
|
|
485
|
-
savedTitle: t("savedTitle"),
|
|
486
|
-
savedBody: t("savedBody"),
|
|
487
|
-
}}
|
|
488
|
-
onSubmit={isCheckout ? handlePay : handleSave}
|
|
489
|
-
pending={pending}
|
|
490
|
-
{...(error !== undefined ? { error } : {})}
|
|
491
|
-
saved={!isCheckout && saved}
|
|
492
|
-
/>
|
|
493
|
-
);
|
|
494
|
-
if (isCheckout) {
|
|
495
|
-
sidePanel = <div className={REGION_CLASS}>{emailStep}</div>;
|
|
496
|
-
active = priceCard;
|
|
497
|
-
} else {
|
|
498
|
-
active = emailStep;
|
|
499
|
-
}
|
|
500
|
-
leading = (
|
|
501
|
-
<Button type="button" variant="ghost" size="big" onClick={back} disabled={pending}>
|
|
502
|
-
{t("back")}
|
|
503
|
-
</Button>
|
|
504
|
-
);
|
|
505
|
-
trailing =
|
|
506
|
-
!isCheckout && saved ? undefined : (
|
|
507
|
-
<Button
|
|
508
|
-
type="submit"
|
|
509
|
-
form={EMAIL_FORM_ID}
|
|
510
|
-
size="big"
|
|
511
|
-
disabled={pending}
|
|
512
|
-
data-testid={isCheckout ? "flow-results-pay" : "flow-results-save"}
|
|
513
|
-
>
|
|
514
|
-
{t(isCheckout ? "pay" : "emailMePlan")}
|
|
515
|
-
</Button>
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
return (
|
|
520
|
-
<FlowFrame
|
|
521
|
-
barMode="explicit"
|
|
522
|
-
value={1}
|
|
523
|
-
max={1}
|
|
524
|
-
sidePanel={sidePanel}
|
|
525
|
-
leading={leading}
|
|
526
|
-
trailing={trailing}
|
|
527
|
-
data-testid="flow-results-frame"
|
|
528
|
-
>
|
|
529
|
-
{view === "checkout" ? active : <div className={REGION_CLASS}>{active}</div>}
|
|
530
|
-
</FlowFrame>
|
|
531
|
-
);
|
|
532
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ResultsEmailStep — the results page's in-place email step (#4842 FR2/FR4),
|
|
5
|
-
* mirroring the FlowStepper single-field topology (#4806 FR1/FR4, DESIGN
|
|
6
|
-
* §7.4): the hero question is the visible `<Heading>` AND the field's own
|
|
7
|
-
* `sr-only` label — one visible question, one answer box, no second small
|
|
8
|
-
* label. Enter submits (a one-text-field form submits implicitly); the
|
|
9
|
-
* frame's bar button submits the same form by `form={formId}`.
|
|
10
|
-
*
|
|
11
|
-
* Client-side validation is the ONLY thing that happens before the caller's
|
|
12
|
-
* `onSubmit` fires: an invalid address renders the validation message and
|
|
13
|
-
* calls nothing (AC-1 — no request leaves on an invalid submit). The
|
|
14
|
-
* saved micro-state (FR4) renders in place of the field once `saved` is set.
|
|
15
|
-
*
|
|
16
|
-
* Every visible string is a prop (FR8 / `customer-facing-i18n`) — no bare
|
|
17
|
-
* JSX text node lives in this file (§5.2).
|
|
18
|
-
*/
|
|
19
|
-
import { Form, Heading, Input, Label, Text } from "@working-theory/ui";
|
|
20
|
-
import { flowSaveInputSchema } from "@working-theory/validation";
|
|
21
|
-
import type { FormEvent, ReactElement } from "react";
|
|
22
|
-
import { useState } from "react";
|
|
23
|
-
|
|
24
|
-
export interface ResultsEmailStepLabels {
|
|
25
|
-
/** The validation message for a malformed address. */
|
|
26
|
-
invalidEmail: string;
|
|
27
|
-
placeholder: string;
|
|
28
|
-
savedTitle: string;
|
|
29
|
-
savedBody: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface ResultsEmailStepProps {
|
|
33
|
-
/** The hero question — visible heading + the field's sr-only label. */
|
|
34
|
-
title: string;
|
|
35
|
-
/** The form's id, so a bar button outside it can submit it (`form={id}`). */
|
|
36
|
-
formId: string;
|
|
37
|
-
labels: ResultsEmailStepLabels;
|
|
38
|
-
/** Fires ONLY with a schema-valid email. */
|
|
39
|
-
onSubmit: (email: string) => void | Promise<void>;
|
|
40
|
-
pending: boolean;
|
|
41
|
-
/** A server-side failure message; rendered as an alert under the field. */
|
|
42
|
-
error?: string;
|
|
43
|
-
/** FR4 — the saved micro-state replaces the field. */
|
|
44
|
-
saved?: boolean;
|
|
45
|
-
"data-testid"?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const emailSchema = flowSaveInputSchema.shape.email;
|
|
49
|
-
|
|
50
|
-
export function ResultsEmailStep({
|
|
51
|
-
title,
|
|
52
|
-
formId,
|
|
53
|
-
labels,
|
|
54
|
-
onSubmit,
|
|
55
|
-
pending,
|
|
56
|
-
error,
|
|
57
|
-
saved = false,
|
|
58
|
-
"data-testid": dataTestId = "results-email-step",
|
|
59
|
-
}: ResultsEmailStepProps): ReactElement {
|
|
60
|
-
const [email, setEmail] = useState("");
|
|
61
|
-
const [validationError, setValidationError] = useState<string | undefined>();
|
|
62
|
-
const inputId = `${formId}-email`;
|
|
63
|
-
const messageId = `${formId}-message`;
|
|
64
|
-
|
|
65
|
-
if (saved) {
|
|
66
|
-
return (
|
|
67
|
-
<div data-testid={`${dataTestId}-saved`} role="status">
|
|
68
|
-
<Heading level={2}>{labels.savedTitle}</Heading>
|
|
69
|
-
<Text variant="body" tone="muted" className="mt-block-tight">
|
|
70
|
-
{labels.savedBody}
|
|
71
|
-
</Text>
|
|
72
|
-
</div>
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function handleSubmit(_event: FormEvent<HTMLFormElement>): void {
|
|
77
|
-
if (pending) return;
|
|
78
|
-
const parsed = emailSchema.safeParse(email);
|
|
79
|
-
if (!parsed.success) {
|
|
80
|
-
setValidationError(labels.invalidEmail);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
setValidationError(undefined);
|
|
84
|
-
void onSubmit(parsed.data);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const message = validationError ?? error;
|
|
88
|
-
|
|
89
|
-
return (
|
|
90
|
-
<div data-testid={dataTestId}>
|
|
91
|
-
<Heading level={2}>{title}</Heading>
|
|
92
|
-
<Form id={formId} className="mt-block-loose" onSubmit={handleSubmit}>
|
|
93
|
-
<Label htmlFor={inputId} className="sr-only">
|
|
94
|
-
{title}
|
|
95
|
-
</Label>
|
|
96
|
-
<Input
|
|
97
|
-
id={inputId}
|
|
98
|
-
name="email"
|
|
99
|
-
type="email"
|
|
100
|
-
size="big"
|
|
101
|
-
autoComplete="email"
|
|
102
|
-
inputMode="email"
|
|
103
|
-
placeholder={labels.placeholder}
|
|
104
|
-
value={email}
|
|
105
|
-
onChange={(event) => setEmail(event.target.value)}
|
|
106
|
-
disabled={pending}
|
|
107
|
-
aria-invalid={message !== undefined ? true : undefined}
|
|
108
|
-
aria-describedby={message !== undefined ? messageId : undefined}
|
|
109
|
-
{...(message !== undefined ? { variant: "error" as const } : {})}
|
|
110
|
-
/>
|
|
111
|
-
{message !== undefined ? (
|
|
112
|
-
<div id={messageId} role="alert" className="mt-block-tight">
|
|
113
|
-
<Text variant="body-sm" className="text-(--color-error-fg)">
|
|
114
|
-
{message}
|
|
115
|
-
</Text>
|
|
116
|
-
</div>
|
|
117
|
-
) : null}
|
|
118
|
-
</Form>
|
|
119
|
-
</div>
|
|
120
|
-
);
|
|
121
|
-
}
|