create-ailk 0.20.0 → 0.22.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.
Files changed (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The standard checkout return pages (#8748 D1, D5, D11; spec
3
+ * docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2).
4
+ *
5
+ * Both pages are noindex, render their namespace's copy, read no search
6
+ * parameter and never import the checkout standard. On any build that is
7
+ * not a spoke they call `notFound()` before reading a translation.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { render } from "@testing-library/react";
13
+
14
+ const mockNotFound = jest.fn(() => {
15
+ throw new Error("NEXT_NOT_FOUND");
16
+ });
17
+ jest.mock("next/navigation", () => ({
18
+ __esModule: true,
19
+ notFound: () => mockNotFound(),
20
+ }));
21
+
22
+ const mockGetTranslations = jest.fn();
23
+ jest.mock("next-intl/server", () => {
24
+ const actual = jest.requireActual("next-intl/server");
25
+ return {
26
+ ...actual,
27
+ getTranslations: (arg: unknown) => {
28
+ mockGetTranslations(arg);
29
+ return actual.getTranslations(arg);
30
+ },
31
+ };
32
+ });
33
+
34
+ const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
35
+ // eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
36
+ jest.mock("../../../../lib/topology.generated", () => ({
37
+ __esModule: true,
38
+ get TOPOLOGY() {
39
+ return mockTopology.value;
40
+ },
41
+ }));
42
+
43
+ // The thank-you page reads a paid session through the package (#8757 D13);
44
+ // with no `session_id` it never calls it, so a stub is enough here.
45
+ // `virtual`: CI runs this suite before the package's dist is built.
46
+ jest.mock(
47
+ "@working-theory/spoke-checkout",
48
+ () => ({ __esModule: true, readPaidCheckoutEmail: jest.fn(async () => null) }),
49
+ { virtual: true },
50
+ );
51
+
52
+ import messages from "../../../../messages/en.json";
53
+ import ThankYouPage, {
54
+ generateMetadata as thankYouMetadata,
55
+ } from "../thank-you/page";
56
+ import UnavailablePage, {
57
+ generateMetadata as unavailableMetadata,
58
+ } from "../unavailable/page";
59
+
60
+ type PageModule = {
61
+ name: string;
62
+ dir: string;
63
+ namespace: "checkoutThankYou" | "checkoutUnavailable";
64
+ Page: typeof ThankYouPage;
65
+ generateMetadata: typeof thankYouMetadata;
66
+ };
67
+
68
+ const PAGES: PageModule[] = [
69
+ {
70
+ name: "thank-you",
71
+ dir: "thank-you",
72
+ namespace: "checkoutThankYou",
73
+ Page: ThankYouPage,
74
+ generateMetadata: thankYouMetadata,
75
+ },
76
+ {
77
+ name: "unavailable",
78
+ dir: "unavailable",
79
+ namespace: "checkoutUnavailable",
80
+ Page: UnavailablePage,
81
+ generateMetadata: unavailableMetadata,
82
+ },
83
+ ];
84
+
85
+ const params = (): Promise<{ locale: string }> =>
86
+ Promise.resolve({ locale: "en" });
87
+
88
+ function source(dir: string): string {
89
+ return readFileSync(join(__dirname, "..", dir, "page.tsx"), "utf8");
90
+ }
91
+
92
+ beforeEach(() => {
93
+ mockNotFound.mockClear();
94
+ mockGetTranslations.mockClear();
95
+ mockTopology.value = "spoke";
96
+ });
97
+
98
+ describe.each(PAGES)("the checkout $name page (#8748 AC-2)", (page) => {
99
+ it("is noindex", async () => {
100
+ const metadata = await page.generateMetadata({ params: params() });
101
+ expect(metadata.robots).toEqual({ index: false, follow: false });
102
+ expect(metadata.title).toBe(messages[page.namespace].title);
103
+ });
104
+
105
+ it("renders its namespace's title", async () => {
106
+ const { getByText } = render(await page.Page({ params: params() }));
107
+ expect(getByText(messages[page.namespace].title)).toBeTruthy();
108
+ });
109
+
110
+ it("never imports lib/checkout, and only the thank-you page reads searchParams (#8757 D13)", () => {
111
+ const text = source(page.dir);
112
+ expect(text).not.toMatch(/lib\/checkout["']/);
113
+ if (page.dir !== "thank-you") expect(text).not.toContain("searchParams");
114
+ });
115
+
116
+ it("calls notFound() before any translation read when the build is a hub (D11)", async () => {
117
+ mockTopology.value = "hub";
118
+ await expect(page.Page({ params: params() })).rejects.toThrow(
119
+ "NEXT_NOT_FOUND",
120
+ );
121
+ await expect(page.generateMetadata({ params: params() })).rejects.toThrow(
122
+ "NEXT_NOT_FOUND",
123
+ );
124
+ expect(mockNotFound).toHaveBeenCalledTimes(2);
125
+ expect(mockGetTranslations).not.toHaveBeenCalled();
126
+ });
127
+ });
128
+
129
+ describe("the checkout unavailable page's way out", () => {
130
+ it("links /contact", async () => {
131
+ const { container } = render(await UnavailablePage({ params: params() }));
132
+ const hrefs = [...container.querySelectorAll("a")].map((a) =>
133
+ a.getAttribute("href"),
134
+ );
135
+ expect(hrefs.some((href) => href === "/contact" || href === "/en/contact")).toBe(
136
+ true,
137
+ );
138
+ });
139
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The checkout thank-you page's "Open your account" link (#8757 D13, AC-9).
3
+ *
4
+ * A `session_id` of a paid session of this site renders the link with the
5
+ * buyer's email (`accountInviteHref`); anything the package answers `null`
6
+ * for renders it with no email. The page is noindex, sends no referrer,
7
+ * renders dynamically, never touches a cookie, and over a visitor's budget
8
+ * of 10 reads per 10 minutes it makes no Stripe read at all.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { render } from "@testing-library/react";
14
+ import { localeHref } from "@working-theory/ui";
15
+
16
+ const mockRead = jest.fn<Promise<string | null>, [{ site: string }, string]>();
17
+ // `virtual`: CI runs this suite before the package's dist is built.
18
+ jest.mock(
19
+ "@working-theory/spoke-checkout",
20
+ () => ({
21
+ __esModule: true,
22
+ readPaidCheckoutEmail: (config: { site: string }, id: string) =>
23
+ mockRead(config, id),
24
+ }),
25
+ { virtual: true },
26
+ );
27
+
28
+ jest.mock("next/navigation", () => ({
29
+ __esModule: true,
30
+ notFound: () => {
31
+ throw new Error("NEXT_NOT_FOUND");
32
+ },
33
+ }));
34
+
35
+ const mockVisitor = { ip: "203.0.113.7" };
36
+ jest.mock("next/headers", () => ({
37
+ __esModule: true,
38
+ headers: jest.fn(async () => ({
39
+ get: (name: string) => (name === "x-forwarded-for" ? mockVisitor.ip : null),
40
+ })),
41
+ }));
42
+
43
+ // eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER and TOPOLOGY are generated per-build constants (#8757 D13), not runtime inputs; no env or HTTP surface selects them.
44
+ jest.mock("../../../../lib/topology.generated", () => ({
45
+ __esModule: true,
46
+ SIGN_IN_PROVIDER: "site",
47
+ TOPOLOGY: "spoke",
48
+ }));
49
+
50
+ import { accountInviteHref } from "../../../../lib/account-invite";
51
+ import { checkoutConfig } from "../../../../lib/checkout-config";
52
+ import messages from "../../../../messages/en.json";
53
+ import { __resetRateLimit } from "../../../../src/lib/rate-limit";
54
+ import * as pageModule from "../thank-you/page";
55
+
56
+ const SESSION = "cs_test_paidOwnSite01";
57
+ const EMAIL = "buyer@example.com";
58
+
59
+ const params = (): Promise<{ locale: string }> =>
60
+ Promise.resolve({ locale: "en" });
61
+ const search = (q: Record<string, string>) => Promise.resolve(q);
62
+
63
+ async function linkHref(
64
+ q: Record<string, string> = { session_id: SESSION },
65
+ ): Promise<string> {
66
+ const { getByTestId, unmount } = render(
67
+ await pageModule.default({ params: params(), searchParams: search(q) }),
68
+ );
69
+ const link = getByTestId("checkout-open-account");
70
+ expect(link.textContent).toBe(messages.checkoutThankYou.openAccount);
71
+ const href = link.getAttribute("href") ?? "";
72
+ unmount();
73
+ return href;
74
+ }
75
+
76
+ beforeEach(() => {
77
+ mockRead.mockReset();
78
+ __resetRateLimit();
79
+ mockVisitor.ip = "203.0.113.7";
80
+ });
81
+
82
+ describe("the thank-you page's account link (#8757 D13, AC-9)", () => {
83
+ it("links accountInviteHref(locale, email) for a paid own-site session", async () => {
84
+ mockRead.mockResolvedValue(EMAIL);
85
+ expect(await linkHref()).toBe(accountInviteHref("en", EMAIL));
86
+ expect(mockRead).toHaveBeenCalledWith(
87
+ { site: checkoutConfig.site },
88
+ SESSION,
89
+ );
90
+ });
91
+
92
+ it("links with no email parameter when the read answers null", async () => {
93
+ mockRead.mockResolvedValue(null);
94
+ const href = await linkHref();
95
+ expect(href).not.toContain("email=");
96
+ expect(href).toBe(localeHref("en", "/account"));
97
+ });
98
+
99
+ it("links with no email and makes no read when there is no session_id", async () => {
100
+ expect(await linkHref({})).not.toContain("email=");
101
+ expect(mockRead).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("makes no read on the 11th visit from one visitor within 10 minutes", async () => {
105
+ mockRead.mockResolvedValue(EMAIL);
106
+ for (let i = 0; i < 10; i += 1) await linkHref();
107
+ expect(mockRead).toHaveBeenCalledTimes(10);
108
+ const href = await linkHref();
109
+ expect(mockRead).toHaveBeenCalledTimes(10);
110
+ expect(href).not.toContain("email=");
111
+ });
112
+
113
+ it("counts each visitor separately", async () => {
114
+ mockRead.mockResolvedValue(EMAIL);
115
+ for (let i = 0; i < 10; i += 1) await linkHref();
116
+ mockVisitor.ip = "198.51.100.9";
117
+ expect(await linkHref()).toBe(accountInviteHref("en", EMAIL));
118
+ expect(mockRead).toHaveBeenCalledTimes(11);
119
+ });
120
+
121
+ it("is noindex, sends no referrer and renders dynamically", async () => {
122
+ const metadata = await pageModule.generateMetadata({ params: params() });
123
+ expect(metadata.referrer).toBe("no-referrer");
124
+ expect(metadata.robots).toEqual({ index: false, follow: false });
125
+ expect(pageModule.dynamic).toBe("force-dynamic");
126
+ });
127
+
128
+ it("never reads or sets a cookie", () => {
129
+ const text = readFileSync(
130
+ join(__dirname, "..", "thank-you", "page.tsx"),
131
+ "utf8",
132
+ );
133
+ expect(text).not.toMatch(/\bcookies\b/);
134
+ expect(text).not.toMatch(/\.set\(/);
135
+ });
136
+ });
@@ -0,0 +1,114 @@
1
+ // The checkout thank-you page — Standard spoke checkout page (#8748).
2
+ // Kept current by `create-ailk update`: do not edit it. This site's checkout
3
+ // data is lib/checkout-config.ts, the one file to edit.
4
+ // Stripe returns a buyer here after paying. It calls neither the hub nor any
5
+ // fulfilment: the webhook is what fulfils the order. It reads the one
6
+ // `session_id` Stripe appended, to offer "Open your account" with the buyer's
7
+ // email (#8757 D13): a paid session of this site, under 24 hours old, read
8
+ // within a per-visitor budget, held per server instance (best effort: the
9
+ // session id's length, not this budget, is what stops guessing). It never
10
+ // sets a cookie and never signs anyone in. It is served on a spoke only: any other build answers notFound().
11
+ import { readPaidCheckoutEmail } from "@working-theory/spoke-checkout";
12
+ import { Button, Container, Link, localeHref } from "@working-theory/ui";
13
+ import { SectionRenderer } from "@working-theory/ui/sections";
14
+ import type { PageSection } from "@working-theory/validation";
15
+ import type { Metadata } from "next";
16
+ import { headers } from "next/headers";
17
+ import { notFound } from "next/navigation";
18
+ import { getTranslations } from "next-intl/server";
19
+ import type { ReactElement } from "react";
20
+
21
+ import { accountInviteHref } from "../../../../lib/account-invite";
22
+ import { checkoutConfig } from "../../../../lib/checkout-config";
23
+ import { TOPOLOGY } from "../../../../lib/topology.generated";
24
+ import {
25
+ checkRateLimit,
26
+ clientIpFromHeaders,
27
+ } from "../../../../src/lib/rate-limit";
28
+
29
+ // Next sends `private, no-cache, no-store` for a force-dynamic render, so the
30
+ // page (it can carry an email) is never cached (#8757 D13).
31
+ export const dynamic = "force-dynamic";
32
+
33
+ /** The Stripe reads one visitor may spend: 10 per 10 minutes (#8757 D13). */
34
+ const PAID_EMAIL_READS = 10;
35
+ const PAID_EMAIL_WINDOW_MS = 10 * 60 * 1000;
36
+
37
+ interface Props {
38
+ params: Promise<{ locale: string }>;
39
+ searchParams?: Promise<Record<string, string | string[] | undefined>>;
40
+ }
41
+
42
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
43
+ if (TOPOLOGY !== "spoke") notFound();
44
+ const { locale } = await params;
45
+ const t = await getTranslations({ locale, namespace: "checkoutThankYou" });
46
+ return {
47
+ title: t("title"),
48
+ robots: { index: false, follow: false },
49
+ referrer: "no-referrer",
50
+ };
51
+ }
52
+
53
+ /** The buyer's email, or `null` — over the visitor's budget, no Stripe call. */
54
+ async function paidEmail(
55
+ sessionId: string | string[] | undefined,
56
+ ): Promise<string | null> {
57
+ // A repeated `session_id` is ignored, like a missing one.
58
+ if (typeof sessionId !== "string" || sessionId === "") return null;
59
+ const visitor = clientIpFromHeaders(await headers());
60
+ if (
61
+ !checkRateLimit(
62
+ `checkout-paid-email:${visitor}`,
63
+ PAID_EMAIL_READS,
64
+ PAID_EMAIL_WINDOW_MS,
65
+ )
66
+ ) {
67
+ return null;
68
+ }
69
+ return readPaidCheckoutEmail({ site: checkoutConfig.site }, sessionId);
70
+ }
71
+
72
+ export default async function Page({
73
+ params,
74
+ searchParams,
75
+ }: Props): Promise<ReactElement> {
76
+ if (TOPOLOGY !== "spoke") notFound();
77
+ const { locale } = await params;
78
+ const t = await getTranslations({ locale, namespace: "checkoutThankYou" });
79
+ const sessionId = (await searchParams)?.session_id;
80
+ const email = await paidEmail(sessionId);
81
+ const href =
82
+ (email === null ? null : accountInviteHref(locale, email)) ??
83
+ localeHref(locale, "/account");
84
+ const sections: PageSection[] = [
85
+ {
86
+ id: "checkout-thank-you",
87
+ type: "content-block",
88
+ data: { headline: t("title"), body: t("body") },
89
+ },
90
+ ];
91
+ return (
92
+ <main id="main">
93
+ <SectionRenderer
94
+ sections={sections}
95
+ locale={locale}
96
+ leadingSectionOwnsTitle
97
+ />
98
+ {/* Session Replay records the DOM, so the link (it can carry the email) is blocked from it. */}
99
+ <Container className="pb-section">
100
+ <div data-sentry-block>
101
+ <Button asChild variant="outline" className="min-h-11">
102
+ <Link
103
+ href={href}
104
+ variant="bare"
105
+ data-testid="checkout-open-account"
106
+ >
107
+ {t("openAccount")}
108
+ </Link>
109
+ </Button>
110
+ </div>
111
+ </Container>
112
+ </main>
113
+ );
114
+ }
@@ -0,0 +1,51 @@
1
+ // The checkout unavailable page — Standard spoke checkout page (#8748).
2
+ // Kept current by `create-ailk update`: do not edit it. This site's checkout
3
+ // data is lib/checkout-config.ts, the one file to edit.
4
+ // Every refused buy lands here. It never says why: a reason is a probe's
5
+ // answer, and the buyer needs only that nothing was charged.
6
+ // It is served on a spoke only: any other build answers notFound().
7
+ import { SectionRenderer } from "@working-theory/ui/sections";
8
+ import type { PageSection } from "@working-theory/validation";
9
+ import type { Metadata } from "next";
10
+ import { notFound } from "next/navigation";
11
+ import { getTranslations } from "next-intl/server";
12
+ import type { ReactElement } from "react";
13
+
14
+ import { TOPOLOGY } from "../../../../lib/topology.generated";
15
+
16
+ interface Props {
17
+ params: Promise<{ locale: string }>;
18
+ }
19
+
20
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
21
+ if (TOPOLOGY !== "spoke") notFound();
22
+ const { locale } = await params;
23
+ const t = await getTranslations({ locale, namespace: "checkoutUnavailable" });
24
+ return { title: t("title"), robots: { index: false, follow: false } };
25
+ }
26
+
27
+ export default async function Page({ params }: Props): Promise<ReactElement> {
28
+ if (TOPOLOGY !== "spoke") notFound();
29
+ const { locale } = await params;
30
+ const t = await getTranslations({ locale, namespace: "checkoutUnavailable" });
31
+ const sections: PageSection[] = [
32
+ {
33
+ id: "checkout-unavailable",
34
+ type: "content-block",
35
+ data: { headline: t("title"), body: t("body") },
36
+ },
37
+ {
38
+ id: "checkout-unavailable-cta",
39
+ type: "cta",
40
+ data: {
41
+ headline: t("ctaHeadline"),
42
+ primaryCta: { label: t("ctaLabel"), href: "/contact" },
43
+ },
44
+ },
45
+ ];
46
+ return (
47
+ <main id="main">
48
+ <SectionRenderer sections={sections} locale={locale} leadingSectionOwnsTitle />
49
+ </main>
50
+ );
51
+ }
@@ -79,7 +79,7 @@ describe("ClientsPage — snapshot regression", () => {
79
79
  });
80
80
  const { container } = render(element);
81
81
  expect(container.innerHTML).toMatchInlineSnapshot(
82
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><h1>Our Clients</h1><p data-testid="clients-description" class="mt-block-tight text-lead">Companies that trust us.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
82
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><h1>Our Clients</h1><p data-testid="clients-description" class="mt-block-tight text-lead">Companies that trust us.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
83
83
  );
84
84
  });
85
85
  });
@@ -81,7 +81,11 @@ function fireIntakeTurn(input: Parameters<typeof recordIntakeTurn>[0]): void {
81
81
  .catch(() => {});
82
82
  }
83
83
 
84
- /** Builds the FormData captureLeadAction expects from the engine's answers. */
84
+ /**
85
+ * Builds the FormData captureLeadAction expects from the engine's answers.
86
+ * Called only once the terms turn was accepted, so it posts consent=true
87
+ * (#8730 D2) — the action checks it for every source.
88
+ */
85
89
  function answersToFormData(answers: Record<string, string>): FormData {
86
90
  const formData = new FormData();
87
91
  if (answers["name"] !== undefined) formData.set("name", answers["name"]);
@@ -89,6 +93,7 @@ function answersToFormData(answers: Record<string, string>): FormData {
89
93
  if (answers["message"] !== undefined)
90
94
  formData.set("message", answers["message"]);
91
95
  formData.set("source", "conversational-intake");
96
+ formData.set("consent", "true");
92
97
  return formData;
93
98
  }
94
99
 
@@ -116,6 +121,10 @@ export function ConversationalIntakeWrapper({
116
121
  messagePrompt: t("intake.messagePrompt"),
117
122
  emailAck: t("intake.emailAck"),
118
123
  emailPrompt: t("intake.emailPrompt"),
124
+ consentAck: t("intake.consentAck"),
125
+ consentPrompt: t("intake.consentPrompt"),
126
+ consentAccept: t("intake.consentAccept"),
127
+ consentDecline: t("intake.consentDecline"),
119
128
  skipMessage: t("intake.skipMessage"),
120
129
  completionMessage: t("intake.completionMessage"),
121
130
  }),
@@ -162,6 +171,13 @@ export function ConversationalIntakeWrapper({
162
171
  status: "completed",
163
172
  locale,
164
173
  });
174
+ // #8730 D2 — the engine records the tapped pill's label; anything but
175
+ // the accept label is not acceptance, so nothing is sent and the form,
176
+ // with its own terms box, takes over.
177
+ if (answers["consent"] !== t("intake.consentAccept")) {
178
+ fallBackToForm(answers);
179
+ return;
180
+ }
165
181
  void (async () => {
166
182
  const result = await captureLeadAction(null, answersToFormData(answers));
167
183
  if (result?.kind !== "success") {
@@ -200,6 +216,13 @@ export function ConversationalIntakeWrapper({
200
216
  action={captureLeadAction}
201
217
  text={contactFormText(t)}
202
218
  termsHref={localeHref(locale, "/terms")}
219
+ // #8661 D7 — the same three policy links /contact passes, so the
220
+ // catalog's three-link statement resolves here too.
221
+ policies={{
222
+ terms: localeHref(locale, "/terms"),
223
+ privacy: localeHref(locale, "/privacy"),
224
+ working_agreement: localeHref(locale, "/working-agreement"),
225
+ }}
203
226
  initialValues={initialValues}
204
227
  />
205
228
  </Container>
@@ -94,6 +94,11 @@ async function flushMicrotasks(): Promise<void> {
94
94
  });
95
95
  }
96
96
 
97
+ /** #8730 D2 — the intake's own terms turn: tap the accept pill. */
98
+ function acceptTerms(): void {
99
+ fireEvent.click(screen.getByTestId(`${TEST_ID}-pill-accept`));
100
+ }
101
+
97
102
  function answer(text: string): void {
98
103
  const input = screen.getByTestId(`${TEST_ID}-free-text-input`);
99
104
  fireEvent.change(input, { target: { value: text } });
@@ -117,16 +122,18 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
117
122
  advanceThinking(); // turn 3 (email) revealed
118
123
 
119
124
  answer("jane@example.com");
125
+ advanceThinking(); // turn 4 (terms) revealed
126
+ acceptTerms();
120
127
  advanceThinking(); // completion revealed, onComplete fires
121
128
 
122
129
  expect(mockSubmit).toHaveBeenCalledTimes(1);
123
130
  const body = mockSubmit.mock.calls[0][0] as {
124
- siteId: string;
125
131
  source: string;
126
132
  email: string;
127
133
  payload: { name: string; message?: string };
128
134
  };
129
- expect(body.siteId).toBe("default");
135
+ // #8747 — no siteId: the API names the workspace itself.
136
+ expect(body).not.toHaveProperty("siteId");
130
137
  expect(body.source).toBe("conversational-intake");
131
138
  expect(body.email).toBe("jane@example.com");
132
139
  expect(body.payload.name).toBe("Jane Smith");
@@ -147,6 +154,8 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
147
154
  answer("I'd like a demo.");
148
155
  advanceThinking();
149
156
  answer("jane@example.com");
157
+ advanceThinking(); // turn 4 (terms) revealed
158
+ acceptTerms();
150
159
  advanceThinking();
151
160
  await flushMicrotasks();
152
161
 
@@ -165,6 +174,68 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
165
174
  });
166
175
  });
167
176
 
177
+ describe("ConversationalIntakeWrapper — the intake's own terms turn (#8730 D2)", () => {
178
+ /** Answers the three open turns and reveals the terms turn. */
179
+ function reachTerms(): void {
180
+ render(<ConversationalIntakeWrapper />);
181
+ advanceThinking();
182
+ answer("Jane Smith");
183
+ advanceThinking();
184
+ answer("I'd like a demo.");
185
+ advanceThinking();
186
+ answer("jane@example.com");
187
+ advanceThinking(); // turn 4 (terms) revealed
188
+ }
189
+
190
+ it("asks for the terms before it submits, offering accept and decline", () => {
191
+ reachTerms();
192
+
193
+ expect(screen.getByText("intake.consentPrompt")).toBeInTheDocument();
194
+ expect(screen.getByTestId(`${TEST_ID}-pill-accept`)).toHaveTextContent("intake.consentAccept");
195
+ expect(screen.getByTestId(`${TEST_ID}-pill-decline`)).toHaveTextContent("intake.consentDecline");
196
+ expect(mockSubmit).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("accepting submits the lead carrying consent: true", async () => {
200
+ mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_abc123", persisted: "db" } });
201
+
202
+ reachTerms();
203
+ acceptTerms();
204
+ advanceThinking();
205
+ await flushMicrotasks();
206
+
207
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
208
+ const body = mockSubmit.mock.calls[0][0] as { source: string; consent: unknown };
209
+ expect(body.source).toBe("conversational-intake");
210
+ expect(body.consent).toBe(true);
211
+ });
212
+
213
+ it("declining submits nothing and shows the form, prefilled, with its own terms box", async () => {
214
+ reachTerms();
215
+ fireEvent.click(screen.getByTestId(`${TEST_ID}-pill-decline`));
216
+ advanceThinking();
217
+ await flushMicrotasks();
218
+
219
+ expect(mockSubmit).not.toHaveBeenCalled();
220
+ const stub = screen.getByTestId("contact-form-stub");
221
+ expect(JSON.parse(stub.getAttribute("data-initial-values") ?? "{}")).toMatchObject({
222
+ name: "Jane Smith",
223
+ email: "jane@example.com",
224
+ message: "I'd like a demo.",
225
+ });
226
+ });
227
+
228
+ it("typing anything but the accept answer is not acceptance", async () => {
229
+ reachTerms();
230
+ answer("sure, whatever");
231
+ advanceThinking();
232
+ await flushMicrotasks();
233
+
234
+ expect(mockSubmit).not.toHaveBeenCalled();
235
+ expect(screen.getByTestId("contact-form-stub")).toBeInTheDocument();
236
+ });
237
+ });
238
+
168
239
  describe("ConversationalIntakeWrapper — Skip (AC2)", () => {
169
240
  it("renders the existing form on Skip, carrying over the answers captured so far", () => {
170
241
  render(<ConversationalIntakeWrapper />);
@@ -199,6 +270,8 @@ describe("ConversationalIntakeWrapper — per-turn abandonment analytics (AC3)",
199
270
  answer("I'd like a demo.");
200
271
  advanceThinking();
201
272
  answer("jane@example.com");
273
+ advanceThinking(); // turn 4 (terms) revealed
274
+ acceptTerms();
202
275
  advanceThinking();
203
276
  await flushMicrotasks(); // recordIntakeTurn is dispatched on a later microtask (#6465)
204
277
 
@@ -211,10 +284,10 @@ describe("ConversationalIntakeWrapper — per-turn abandonment analytics (AC3)",
211
284
  true,
212
285
  );
213
286
  const inProgress = calls.filter((c) => c.status === "in_progress");
214
- expect(inProgress.map((c) => c.turnIndex)).toEqual([1, 2, 3, 4]);
287
+ expect(inProgress.map((c) => c.turnIndex)).toEqual([1, 2, 3, 4, 5]);
215
288
 
216
289
  const terminal = calls[calls.length - 1];
217
- expect(terminal).toMatchObject({ status: "completed", turnIndex: 4 });
290
+ expect(terminal).toMatchObject({ status: "completed", turnIndex: 5 });
218
291
 
219
292
  // PII-free: only turnIndex/status/locale ever leave the client.
220
293
  for (const call of mockTrack.mock.calls) {
@@ -317,6 +390,8 @@ describe("ConversationalIntakeWrapper — a failing analytics call never strands
317
390
  answer("I'd like a demo.");
318
391
  advanceThinking();
319
392
  answer("jane@example.com");
393
+ advanceThinking(); // turn 4 (terms) revealed
394
+ acceptTerms();
320
395
  advanceThinking(); // completion revealed, onComplete fires
321
396
  await flushMicrotasks();
322
397