create-ailk 0.27.0 → 0.28.1

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.
Files changed (219) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/chrome-readers.d.ts +21 -5
  16. package/dist/lib/chrome-readers.js +128 -20
  17. package/dist/lib/declared-env.d.ts +6 -0
  18. package/dist/lib/declared-env.js +2 -0
  19. package/dist/lib/env-catalog.js +20 -0
  20. package/dist/lib/import-graph.d.ts +16 -0
  21. package/dist/lib/import-graph.js +53 -0
  22. package/dist/lib/site-modules.d.ts +8 -0
  23. package/dist/lib/site-modules.js +34 -0
  24. package/dist/module-architecture.js +23 -14
  25. package/dist/paid-sections.d.ts +17 -0
  26. package/dist/paid-sections.js +39 -0
  27. package/dist/programmatic.d.ts +16 -0
  28. package/dist/programmatic.js +73 -4
  29. package/dist/remove-module.d.ts +98 -0
  30. package/dist/remove-module.js +620 -0
  31. package/dist/seed-flows.d.ts +2 -2
  32. package/dist/seed-flows.js +16 -9
  33. package/dist/site-roster.js +4 -1
  34. package/dist/starter-grid.d.ts +26 -0
  35. package/dist/starter-grid.js +34 -0
  36. package/dist/surfaces.d.ts +26 -0
  37. package/dist/surfaces.js +105 -9
  38. package/dist/sync-routes.d.ts +7 -0
  39. package/dist/sync-routes.js +53 -6
  40. package/dist/template-pages.d.ts +36 -0
  41. package/dist/template-pages.js +129 -0
  42. package/dist/template-record.d.ts +13 -4
  43. package/dist/template-record.js +8 -4
  44. package/dist/topology.d.ts +2 -1
  45. package/dist/topology.js +4 -2
  46. package/dist/update.d.ts +34 -2
  47. package/dist/update.js +395 -48
  48. package/package.json +3 -3
  49. package/release-scope.json +2 -1
  50. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  51. package/templates/.env.example +5 -2
  52. package/templates/apps/api/.env.example +9 -5
  53. package/templates/apps/api/CLAUDE.md +14 -9
  54. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  55. package/templates/apps/api/src/vercel-handler.ts +8 -1
  56. package/templates/apps/mcp/CLAUDE.md +18 -10
  57. package/templates/apps/web/.env.example +25 -11
  58. package/templates/apps/web/CLAUDE.md +45 -4
  59. package/templates/apps/web/__tests__/setup.ts +7 -0
  60. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  61. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  65. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  67. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  68. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  69. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  70. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  72. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  73. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  74. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  76. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  77. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  78. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  79. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  81. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  82. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  83. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  84. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  85. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  86. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  87. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  88. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  89. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  90. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  91. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  92. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  93. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  105. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  107. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  108. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  109. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  111. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  113. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  114. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  115. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  116. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  117. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  118. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  119. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  120. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  121. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  122. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  124. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  126. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  127. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  128. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  129. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  130. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  132. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  133. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  134. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  135. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  136. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  137. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  138. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  139. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  140. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  141. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  142. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  144. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  145. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  147. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  148. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  149. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  150. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  151. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  152. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
  153. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  154. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  156. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  157. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  158. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  159. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  160. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  161. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  162. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  163. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  164. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  165. package/templates/apps/web/auth.ts +102 -50
  166. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  167. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  168. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  169. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  170. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  171. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  172. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
  173. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  174. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  175. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  176. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  177. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  178. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  179. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  180. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  181. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  182. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  183. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  184. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  185. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  186. package/templates/apps/web/lib/auth-secret.ts +57 -0
  187. package/templates/apps/web/lib/bot-check.ts +138 -0
  188. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  189. package/templates/apps/web/lib/deck-page.ts +6 -3
  190. package/templates/apps/web/lib/docs-index.ts +100 -0
  191. package/templates/apps/web/lib/docs-path.ts +94 -0
  192. package/templates/apps/web/lib/hub-children.tsx +318 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  195. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  196. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  197. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  198. package/templates/apps/web/lib/page-chrome.ts +367 -0
  199. package/templates/apps/web/lib/posting-title.ts +26 -4
  200. package/templates/apps/web/lib/pricing-page.ts +430 -0
  201. package/templates/apps/web/lib/relay.ts +31 -106
  202. package/templates/apps/web/lib/route-family.ts +69 -0
  203. package/templates/apps/web/lib/service-listing.ts +89 -0
  204. package/templates/apps/web/lib/services-path.ts +97 -0
  205. package/templates/apps/web/lib/site-header.ts +94 -0
  206. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  207. package/templates/apps/web/lib/template-listing.ts +169 -0
  208. package/templates/apps/web/messages/en.json +86 -30
  209. package/templates/apps/web/middleware.ts +171 -11
  210. package/templates/apps/web/next.config.mjs +211 -2
  211. package/templates/apps/web/package.json +1 -1
  212. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  213. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  214. package/templates/apps/web/sentry.server.config.ts +30 -2
  215. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  216. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  217. package/templates/apps/web/src/lib/routes.ts +15 -1
  218. package/templates/tools/declared-env.ts +7 -0
  219. package/versions.json +48 -48
@@ -0,0 +1,513 @@
1
+ // oss: false
2
+ "use client";
3
+
4
+ /**
5
+ * ResultsClient — the results permalink page's composition + view state
6
+ * machine (#4842): `results` → `checkout` | `save` → saved.
7
+ *
8
+ * Runs the already-shipped pure `recommend()` on the decoded answers and
9
+ * composes the LOCKED two-region frame (`FlowFrame`, DESIGN §2/§3/§7.2):
10
+ *
11
+ * - **results** — side panel = `FlowSidePanel` `safe-default` (the top fit,
12
+ * its shown price when the register can price it, the always-present
13
+ * Book-a-Call rail, §7.3); active region = the fits as
14
+ * `RecommendationCard` (first fit `emphasis: "recommended"`, each with a
15
+ * per-offer terminal — D2) + `PairingCard` when one matched; bar
16
+ * `leading` = "Email me this plan" (D5).
17
+ * - **checkout** — Get Started performs DESIGN §7.6's panel FLIP: the
18
+ * email step takes the frame's first (side) slot — "fields left" — and
19
+ * the configurable price card (#4865, D3) takes the second (active)
20
+ * slot — "price card right"; the card is controlled here, so its
21
+ * seat / period / credit-stop state is EXACTLY what Pay sends. Below `md`
22
+ * the frame stacks its slots in that same order, so the email step is
23
+ * first on mobile and the card is the scroll-to. Bar `leading` = Back,
24
+ * `trailing` = Pay.
25
+ * - **save** — the same email step with the save framing; the side panel
26
+ * stays the safe-default card (FR4); success renders the saved state.
27
+ *
28
+ * The three frame regions keep their DOM identity across every transition
29
+ * (#4806 FR7 — the frame is rendered once; only its slots change).
30
+ *
31
+ * Checkout is commerce's optional add-on to this signal-collectors page
32
+ * (#6786): the price card and Pay come from `ResultsCheckoutContext`
33
+ * (`checkout-seam.ts`), which commerce's `layout.tsx` provides. Without it —
34
+ * a scaffold with no commerce — no fit offers Get Started (every terminal is
35
+ * Book a Call), so the checkout view is unreachable; the results, the
36
+ * Book-a-Call rail and "Email me this plan" are unchanged.
37
+ *
38
+ * Before a terminal or the save CTA is selected AND a valid email submitted,
39
+ * this component makes no request of any kind (FR5 / PRD L108): the only
40
+ * network call on the page is the server component's `flows.get`.
41
+ *
42
+ * `RecommendRegister` (D6, #4770) is a structural `Pick` of the five fields
43
+ * `recommend()` / `price()` read — `key`, `offers`, `pairings`, `rateTable`,
44
+ * `scoring` — so a `FlowConfig` needs only a `key` to satisfy it; the
45
+ * compiled config carries the register's own key on `experiment.key`.
46
+ */
47
+ import type {
48
+ RecommendationCardPrice,
49
+ RecommendationCardTerminal,
50
+ } from "@working-theory/ui";
51
+ import {
52
+ Button,
53
+ FlowFrame,
54
+ Heading,
55
+ PairingCard,
56
+ RecommendationCard,
57
+ Text,
58
+ } from "@working-theory/ui";
59
+ import {
60
+ FlowSidePanel,
61
+ type FlowSidePanelView,
62
+ } from "@working-theory/ui-signal-collectors";
63
+ import type {
64
+ FlowConfig,
65
+ FlowSaveInput,
66
+ RecommendRegister,
67
+ WaitlistBillingPeriod,
68
+ } from "@working-theory/validation";
69
+ import { recommend, resolveLocalizedString } from "@working-theory/validation";
70
+ import { useTranslations } from "next-intl";
71
+ import type { ReactElement, ReactNode } from "react";
72
+ import { useContext, useEffect, useMemo, useState } from "react";
73
+
74
+ import { saveRecommendationAction } from "../../save.actions";
75
+
76
+ import type {
77
+ ResultsCheckoutConfiguration,
78
+ ResultsCheckoutInput,
79
+ } from "./checkout-seam";
80
+ import { ResultsCheckoutContext } from "./checkout-seam";
81
+ import { ResultsEmailStep } from "./ResultsEmailStep";
82
+
83
+ export type ResultsReturnState = "success" | "cancelled";
84
+
85
+ interface Props {
86
+ config: FlowConfig;
87
+ locale: string;
88
+ answers: Record<string, string | string[]>;
89
+ slug: string;
90
+ /** The encoded permalink payload — the return URLs are built from it. */
91
+ payload: string;
92
+ /** FR7/D6 — the `?checkout=` return micro-state, validated by the page. */
93
+ returnState?: ResultsReturnState;
94
+ }
95
+
96
+ type View = "results" | "checkout" | "save";
97
+
98
+ const EMAIL_FORM_ID = "flow-results-email";
99
+
100
+ // Mirrors `FlowStepperClient.tsx` (#4707) — every path this component builds
101
+ // passes its locale through the same build-time-baked allowlist, never the
102
+ // raw route segment.
103
+ const DEFAULT_LOCALE = "en";
104
+ const ENABLED_LOCALES = (
105
+ process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE
106
+ ).split(",");
107
+
108
+ /** Validate a candidate locale against the configured set; unknown → default. */
109
+ function resolveSafeLocale(candidate: string): string {
110
+ return ENABLED_LOCALES.includes(candidate) ? candidate : DEFAULT_LOCALE;
111
+ }
112
+
113
+ /**
114
+ * Next's redirect control-flow error (`redirect()` inside a Server Action),
115
+ * recognised by its digest — `NEXT_REDIRECT;<push|replace>;<url>;<status>;`
116
+ * (`next/dist/client/components/redirect-error.js`). Never a checkout failure.
117
+ */
118
+ function isNextRedirect(err: unknown): boolean {
119
+ return (
120
+ typeof err === "object" &&
121
+ err !== null &&
122
+ "digest" in err &&
123
+ typeof (err as { digest: unknown }).digest === "string" &&
124
+ (err as { digest: string }).digest.startsWith("NEXT_REDIRECT;")
125
+ );
126
+ }
127
+
128
+ /** D4 — the one `sessionStorage` key `FlowStepperClient` writes. */
129
+ function flowSessionStorageKey(slug: string): string {
130
+ return `ailk:flow-session:${slug}`;
131
+ }
132
+
133
+ /** D2 — an offer is quotable iff the register names a currency AND a seat rate for it. */
134
+ function firstSeatRatePeriod(
135
+ register: RecommendRegister,
136
+ offerKey: string,
137
+ ): WaitlistBillingPeriod | undefined {
138
+ const rateTable = register.rateTable;
139
+ if (rateTable?.currency === undefined) return undefined;
140
+ return rateTable.seatRates.find((rate) => rate.offer === offerKey)?.period;
141
+ }
142
+
143
+ export function ResultsClient({
144
+ config,
145
+ locale,
146
+ answers,
147
+ slug,
148
+ payload,
149
+ returnState,
150
+ }: Props): ReactElement {
151
+ const t = useTranslations("flow");
152
+ const checkout = useContext(ResultsCheckoutContext);
153
+ const { defaultLocale, offers = [], recommendation } = config;
154
+
155
+ const register = useMemo<RecommendRegister>(
156
+ () => ({
157
+ key: config.experiment?.key ?? config.slug,
158
+ offers: config.offers,
159
+ pairings: config.pairings,
160
+ rateTable: config.rateTable,
161
+ scoring: config.scoring,
162
+ }),
163
+ [config],
164
+ );
165
+ const result = useMemo(() => recommend(register, answers), [register, answers]);
166
+ const currency = config.rateTable?.currency;
167
+
168
+ const [view, setView] = useState<View>("results");
169
+ const [selectedOfferKey, setSelectedOfferKey] = useState<string | undefined>();
170
+ const [configuration, setConfiguration] = useState<ResultsCheckoutConfiguration>({
171
+ seats: 1,
172
+ period: "annual",
173
+ creditStop: { kind: "none" },
174
+ });
175
+ const [sessionId, setSessionId] = useState<string | undefined>();
176
+ const [pending, setPending] = useState(false);
177
+ const [error, setError] = useState<string | undefined>();
178
+ const [saved, setSaved] = useState(false);
179
+
180
+ // D4 — read the walk's own session id; a privacy mode that throws, or no
181
+ // entry at all, degrades to cold (the key is simply omitted from the body).
182
+ useEffect(() => {
183
+ try {
184
+ const stored = window.sessionStorage.getItem(flowSessionStorageKey(slug));
185
+ if (stored) setSessionId(stored);
186
+ } catch {
187
+ // cold load
188
+ }
189
+ }, [slug]);
190
+
191
+ const resolve = (value: Parameters<typeof resolveLocalizedString>[0]): string =>
192
+ resolveLocalizedString(value, locale, defaultLocale);
193
+
194
+ const safeLocale = resolveSafeLocale(locale);
195
+ const bookCallHref = `/${safeLocale}/meet`;
196
+
197
+ const periodLabels: Record<WaitlistBillingPeriod, string> = {
198
+ monthly: t("priceCard.periodLabels.monthly"),
199
+ annual: t("priceCard.periodLabels.annual"),
200
+ "one-time": t("priceCard.periodLabels.one-time"),
201
+ };
202
+
203
+ const topFit = result.fits[0];
204
+ const topOffer = topFit
205
+ ? offers.find((offer) => offer.key === topFit.offerKey)
206
+ : undefined;
207
+ const selectedOffer = selectedOfferKey
208
+ ? offers.find((offer) => offer.key === selectedOfferKey)
209
+ : undefined;
210
+
211
+ function startCheckout(offerKey: string): void {
212
+ const period = firstSeatRatePeriod(register, offerKey);
213
+ if (period === undefined) return; // D2 — never quotable → never reaches here
214
+ setSelectedOfferKey(offerKey);
215
+ setConfiguration({ seats: 1, period, creditStop: { kind: "none" } });
216
+ setError(undefined);
217
+ setView("checkout");
218
+ }
219
+
220
+ function openSave(): void {
221
+ setError(undefined);
222
+ setSaved(false);
223
+ setView("save");
224
+ }
225
+
226
+ function back(): void {
227
+ setError(undefined);
228
+ setView("results");
229
+ }
230
+
231
+ function failureMessage(
232
+ kind: string,
233
+ retryAfterSeconds: number | undefined,
234
+ fallbackKey: "checkoutError" | "submitError",
235
+ ): string {
236
+ if (kind === "rate_limited" && retryAfterSeconds !== undefined) {
237
+ return t("rateLimited", { seconds: retryAfterSeconds });
238
+ }
239
+ return t(fallbackKey);
240
+ }
241
+
242
+ async function handlePay(email: string): Promise<void> {
243
+ if (selectedOfferKey === undefined || checkout === undefined) return;
244
+ setPending(true);
245
+ setError(undefined);
246
+ // The Stripe return URLs are built by the action from the app's own
247
+ // origin + this permalink (security review, #4875) — never sent from here.
248
+ const body: ResultsCheckoutInput = {
249
+ answers,
250
+ email,
251
+ locale,
252
+ offerKey: selectedOfferKey,
253
+ seats: configuration.seats,
254
+ period: configuration.period,
255
+ creditStop: configuration.creditStop,
256
+ payload,
257
+ ...(sessionId !== undefined ? { sessionId } : {}),
258
+ };
259
+ try {
260
+ const outcome = await checkout.start(slug, body);
261
+ // On success the action `redirect()`s — a returned value is always a
262
+ // failure.
263
+ if (outcome) {
264
+ setError(failureMessage(outcome.kind, outcome.retryAfterSeconds, "checkoutError"));
265
+ setPending(false);
266
+ }
267
+ } catch (err) {
268
+ // A NEXT_REDIRECT is the SUCCESS path — Stripe's hosted page. It is
269
+ // Next's control-flow error, never a checkout failure: the router is
270
+ // already navigating (the action response carried the redirect), so it
271
+ // is left alone — `pending` stays set and no error renders. Rethrowing
272
+ // from an event handler would reach no framework boundary; it would
273
+ // only be an unhandled rejection. Only a genuine transport failure
274
+ // lands in the error micro-state below.
275
+ if (isNextRedirect(err)) return;
276
+ setError(t("checkoutError"));
277
+ setPending(false);
278
+ }
279
+ }
280
+
281
+ async function handleSave(email: string): Promise<void> {
282
+ setPending(true);
283
+ setError(undefined);
284
+ const body: FlowSaveInput = {
285
+ answers,
286
+ email,
287
+ locale,
288
+ ...(sessionId !== undefined ? { sessionId } : {}),
289
+ };
290
+ try {
291
+ const outcome = await saveRecommendationAction(slug, body);
292
+ if (outcome.ok) {
293
+ setSaved(true);
294
+ } else {
295
+ setError(failureMessage(outcome.kind, outcome.retryAfterSeconds, "submitError"));
296
+ }
297
+ } catch {
298
+ setError(t("submitError"));
299
+ } finally {
300
+ setPending(false);
301
+ }
302
+ }
303
+
304
+ // ── Side panel ──────────────────────────────────────────────────────────
305
+
306
+ const safeDefaultView: FlowSidePanelView | undefined = topOffer
307
+ ? {
308
+ kind: "safe-default",
309
+ anchorTitle: t("sidePanel.anchorTitle"),
310
+ offerName: resolve(topOffer.copy.name),
311
+ ...(currency !== undefined && topOffer.price.kind !== "label"
312
+ ? {
313
+ price: {
314
+ amountMinor: Math.round(topOffer.price.amount * 100),
315
+ currency,
316
+ locale,
317
+ },
318
+ showPrice: true,
319
+ }
320
+ : {}),
321
+ action: { kind: "link", href: bookCallHref, label: t("bookCall") },
322
+ }
323
+ : undefined;
324
+
325
+ // The frame's regions carry no inner padding of their own (FlowFrame.tsx
326
+ // :151/:159) — the consumer pads, exactly as FlowStepper does for its step
327
+ // body (`mx-auto w-full max-w-container-sm p-card`, FlowStepper.tsx:1280).
328
+ const REGION_CLASS = "mx-auto w-full max-w-container-sm p-card";
329
+
330
+ const safeDefaultPanel: ReactNode = safeDefaultView ? (
331
+ <div className={REGION_CLASS}>
332
+ <FlowSidePanel view={safeDefaultView} />
333
+ </div>
334
+ ) : undefined;
335
+
336
+ // DESIGN §7.6 — the configurable price card, rendered into the frame's
337
+ // SECOND slot (right at md+) on the checkout view; the email step takes the
338
+ // first slot. Composed once here, placed below.
339
+ let priceCard: ReactNode;
340
+ if (view === "checkout" && selectedOffer && currency !== undefined && checkout) {
341
+ priceCard = (
342
+ <div className={REGION_CLASS}>
343
+ <checkout.PriceCard
344
+ offer={selectedOffer}
345
+ currency={currency}
346
+ locale={locale}
347
+ {...(defaultLocale !== undefined ? { defaultLocale } : {})}
348
+ register={register}
349
+ value={configuration}
350
+ onChange={setConfiguration}
351
+ />
352
+ </div>
353
+ );
354
+ }
355
+
356
+ // ── Slots ────────────────────────────────────────────────────────────────
357
+ //
358
+ // `sidePanel` is the frame's FIRST slot (left at md+, first in the mobile
359
+ // stack); `active` is its SECOND. On the results and save views the safe-
360
+ // default card is first and the recommendation / email step second; on the
361
+ // checkout view the slots FLIP (DESIGN §7.6 / §2's Checkout row): the email
362
+ // step is first and the price card second.
363
+
364
+ let sidePanel: ReactNode = safeDefaultPanel;
365
+ let active: ReactNode;
366
+ let leading: ReactNode;
367
+ let trailing: ReactNode;
368
+
369
+ if (view === "results") {
370
+ const heading = recommendation ? resolve(recommendation.copy.title) : undefined;
371
+ const body = recommendation?.copy.body ? resolve(recommendation.copy.body) : undefined;
372
+
373
+ active = (
374
+ <div data-testid="flow-results">
375
+ {returnState !== undefined ? (
376
+ <div
377
+ role="status"
378
+ data-testid="flow-results-return-state"
379
+ className="mb-block-loose"
380
+ >
381
+ <Text variant="body-sm" tone="muted">
382
+ {t(returnState === "success" ? "checkoutSuccess" : "checkoutCancelled")}
383
+ </Text>
384
+ </div>
385
+ ) : null}
386
+ {heading ? <Heading level={1}>{heading}</Heading> : null}
387
+ {body ? (
388
+ <Text variant="body" tone="muted" className="mt-block-tight">
389
+ {body}
390
+ </Text>
391
+ ) : null}
392
+ <div className="mt-block-loose flex flex-col gap-4">
393
+ {result.fits.map((fit, index) => {
394
+ const offer = offers.find((o) => o.key === fit.offerKey);
395
+ if (!offer) return null;
396
+ // Get Started only where commerce is on (#6786) AND the offer is quotable (D2).
397
+ const quotable =
398
+ checkout !== undefined &&
399
+ firstSeatRatePeriod(register, offer.key) !== undefined;
400
+ const terminal: RecommendationCardTerminal = quotable
401
+ ? {
402
+ kind: "getStarted",
403
+ label: t("getStarted"),
404
+ onSelect: () => startCheckout(offer.key),
405
+ }
406
+ : {
407
+ kind: "bookCall",
408
+ label: t("bookCall"),
409
+ onSelect: () => window.location.assign(bookCallHref),
410
+ };
411
+ const price: RecommendationCardPrice | undefined =
412
+ currency !== undefined
413
+ ? {
414
+ ...offer.price,
415
+ currency,
416
+ locale,
417
+ ...(defaultLocale !== undefined ? { defaultLocale } : {}),
418
+ periodLabels,
419
+ fromPrefix: t("price.fromPrefix"),
420
+ }
421
+ : undefined;
422
+ return (
423
+ <RecommendationCard
424
+ key={fit.offerKey}
425
+ data-testid={`recommendation-card-${fit.offerKey}`}
426
+ title={resolve(offer.copy.name)}
427
+ {...(offer.copy.description
428
+ ? { description: resolve(offer.copy.description) }
429
+ : {})}
430
+ emphasis={index === 0 ? "recommended" : "default"}
431
+ {...(index === 0 ? { recommendedLabel: t("priceCard.eyebrow") } : {})}
432
+ {...(price !== undefined ? { price } : {})}
433
+ terminal={terminal}
434
+ whyThis={{ label: t("whyThis"), content: resolve(fit.reason) }}
435
+ />
436
+ );
437
+ })}
438
+ {result.pairing ? (
439
+ <PairingCard
440
+ title={t("pairingTitle")}
441
+ pairing={result.pairing}
442
+ offers={offers}
443
+ locale={locale}
444
+ {...(defaultLocale !== undefined ? { defaultLocale } : {})}
445
+ data-testid="recommendation-pairing"
446
+ />
447
+ ) : null}
448
+ </div>
449
+ </div>
450
+ );
451
+ leading = (
452
+ <Button type="button" variant="ghost" size="big" onClick={openSave}>
453
+ {t("emailMePlan")}
454
+ </Button>
455
+ );
456
+ trailing = undefined;
457
+ } else {
458
+ const isCheckout = view === "checkout";
459
+ const emailStep = (
460
+ <ResultsEmailStep
461
+ title={t(isCheckout ? "emailStepTitle" : "emailStepSaveTitle")}
462
+ formId={EMAIL_FORM_ID}
463
+ labels={{
464
+ invalidEmail: t("validationInvalidEmail"),
465
+ placeholder: t("emailPlaceholder"),
466
+ savedTitle: t("savedTitle"),
467
+ savedBody: t("savedBody"),
468
+ }}
469
+ onSubmit={isCheckout ? handlePay : handleSave}
470
+ pending={pending}
471
+ {...(error !== undefined ? { error } : {})}
472
+ saved={!isCheckout && saved}
473
+ />
474
+ );
475
+ if (isCheckout) {
476
+ sidePanel = <div className={REGION_CLASS}>{emailStep}</div>;
477
+ active = priceCard;
478
+ } else {
479
+ active = emailStep;
480
+ }
481
+ leading = (
482
+ <Button type="button" variant="ghost" size="big" onClick={back} disabled={pending}>
483
+ {t("back")}
484
+ </Button>
485
+ );
486
+ trailing =
487
+ !isCheckout && saved ? undefined : (
488
+ <Button
489
+ type="submit"
490
+ form={EMAIL_FORM_ID}
491
+ size="big"
492
+ disabled={pending}
493
+ data-testid={isCheckout ? "flow-results-pay" : "flow-results-save"}
494
+ >
495
+ {t(isCheckout ? "pay" : "emailMePlan")}
496
+ </Button>
497
+ );
498
+ }
499
+
500
+ return (
501
+ <FlowFrame
502
+ barMode="explicit"
503
+ value={1}
504
+ max={1}
505
+ sidePanel={sidePanel}
506
+ leading={leading}
507
+ trailing={trailing}
508
+ data-testid="flow-results-frame"
509
+ >
510
+ {view === "checkout" ? active : <div className={REGION_CLASS}>{active}</div>}
511
+ </FlowFrame>
512
+ );
513
+ }
@@ -0,0 +1,122 @@
1
+ // oss: false
2
+ "use client";
3
+
4
+ /**
5
+ * ResultsEmailStep — the results page's in-place email step (#4842 FR2/FR4),
6
+ * mirroring the FlowStepper single-field topology (#4806 FR1/FR4, DESIGN
7
+ * §7.4): the hero question is the visible `<Heading>` AND the field's own
8
+ * `sr-only` label — one visible question, one answer box, no second small
9
+ * label. Enter submits (a one-text-field form submits implicitly); the
10
+ * frame's bar button submits the same form by `form={formId}`.
11
+ *
12
+ * Client-side validation is the ONLY thing that happens before the caller's
13
+ * `onSubmit` fires: an invalid address renders the validation message and
14
+ * calls nothing (AC-1 — no request leaves on an invalid submit). The
15
+ * saved micro-state (FR4) renders in place of the field once `saved` is set.
16
+ *
17
+ * Every visible string is a prop (FR8 / `customer-facing-i18n`) — no bare
18
+ * JSX text node lives in this file (§5.2).
19
+ */
20
+ import { Form, Heading, Input, Label, Text } from "@working-theory/ui";
21
+ import { flowSaveInputSchema } from "@working-theory/validation";
22
+ import type { FormEvent, ReactElement } from "react";
23
+ import { useState } from "react";
24
+
25
+ export interface ResultsEmailStepLabels {
26
+ /** The validation message for a malformed address. */
27
+ invalidEmail: string;
28
+ placeholder: string;
29
+ savedTitle: string;
30
+ savedBody: string;
31
+ }
32
+
33
+ export interface ResultsEmailStepProps {
34
+ /** The hero question — visible heading + the field's sr-only label. */
35
+ title: string;
36
+ /** The form's id, so a bar button outside it can submit it (`form={id}`). */
37
+ formId: string;
38
+ labels: ResultsEmailStepLabels;
39
+ /** Fires ONLY with a schema-valid email. */
40
+ onSubmit: (email: string) => void | Promise<void>;
41
+ pending: boolean;
42
+ /** A server-side failure message; rendered as an alert under the field. */
43
+ error?: string;
44
+ /** FR4 — the saved micro-state replaces the field. */
45
+ saved?: boolean;
46
+ "data-testid"?: string;
47
+ }
48
+
49
+ const emailSchema = flowSaveInputSchema.shape.email;
50
+
51
+ export function ResultsEmailStep({
52
+ title,
53
+ formId,
54
+ labels,
55
+ onSubmit,
56
+ pending,
57
+ error,
58
+ saved = false,
59
+ "data-testid": dataTestId = "results-email-step",
60
+ }: ResultsEmailStepProps): ReactElement {
61
+ const [email, setEmail] = useState("");
62
+ const [validationError, setValidationError] = useState<string | undefined>();
63
+ const inputId = `${formId}-email`;
64
+ const messageId = `${formId}-message`;
65
+
66
+ if (saved) {
67
+ return (
68
+ <div data-testid={`${dataTestId}-saved`} role="status">
69
+ <Heading level={2}>{labels.savedTitle}</Heading>
70
+ <Text variant="body" tone="muted" className="mt-block-tight">
71
+ {labels.savedBody}
72
+ </Text>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ function handleSubmit(_event: FormEvent<HTMLFormElement>): void {
78
+ if (pending) return;
79
+ const parsed = emailSchema.safeParse(email);
80
+ if (!parsed.success) {
81
+ setValidationError(labels.invalidEmail);
82
+ return;
83
+ }
84
+ setValidationError(undefined);
85
+ void onSubmit(parsed.data);
86
+ }
87
+
88
+ const message = validationError ?? error;
89
+
90
+ return (
91
+ <div data-testid={dataTestId}>
92
+ <Heading level={2}>{title}</Heading>
93
+ <Form id={formId} className="mt-block-loose" onSubmit={handleSubmit}>
94
+ <Label htmlFor={inputId} className="sr-only">
95
+ {title}
96
+ </Label>
97
+ <Input
98
+ id={inputId}
99
+ name="email"
100
+ type="email"
101
+ size="big"
102
+ autoComplete="email"
103
+ inputMode="email"
104
+ placeholder={labels.placeholder}
105
+ value={email}
106
+ onChange={(event) => setEmail(event.target.value)}
107
+ disabled={pending}
108
+ aria-invalid={message !== undefined ? true : undefined}
109
+ aria-describedby={message !== undefined ? messageId : undefined}
110
+ {...(message !== undefined ? { variant: "error" as const } : {})}
111
+ />
112
+ {message !== undefined ? (
113
+ <div id={messageId} role="alert" className="mt-block-tight">
114
+ <Text variant="body-sm" className="text-(--color-error-fg)">
115
+ {message}
116
+ </Text>
117
+ </div>
118
+ ) : null}
119
+ </Form>
120
+ </div>
121
+ );
122
+ }