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,138 @@
1
+ /**
2
+ * contactFormFromConfig (#8661 D6) — a Contact form magnet's config mapped
3
+ * onto the shared `LeadCaptureForm` block's fields, statement and label.
4
+ *
5
+ * AC-3: the seeded config maps to the five-field form in order (the terms box
6
+ * is the statement, not a field); a relabelled subject and a sixth option
7
+ * render; a config the action cannot submit (no `name`, a non-field first
8
+ * step) is `null`; `address` and a second `consent` field are not rendered;
9
+ * copy resolves per locale. AC-5's fallback is the same mapping over the
10
+ * built-in config, which is never `null`.
11
+ */
12
+ import {
13
+ CONTACT_FORM_SUBJECT_OPTIONS,
14
+ CONTACT_FORM_TERMS_STATEMENT,
15
+ contactFormFlowConfig,
16
+ type FlowConfig,
17
+ } from "@working-theory/validation";
18
+
19
+ import { contactFormFromConfig } from "../contact-form-magnet";
20
+
21
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
22
+
23
+ /** A deep copy of the seeded config, so a case can edit it freely. */
24
+ function seeded(): FlowConfig {
25
+ return JSON.parse(JSON.stringify(contactFormFlowConfig)) as FlowConfig;
26
+ }
27
+
28
+ function firstStep(config: FlowConfig): FieldStep {
29
+ return config.steps[0] as FieldStep;
30
+ }
31
+
32
+ describe("contactFormFromConfig — the seeded config (AC-3)", () => {
33
+ const view = contactFormFromConfig(contactFormFlowConfig, "en");
34
+
35
+ it("is never null", () => {
36
+ expect(view).not.toBeNull();
37
+ });
38
+
39
+ it("maps name, email, subject and message, in order", () => {
40
+ expect(view!.fields.map((f) => f.name)).toEqual(["name", "email", "subject", "message"]);
41
+ expect(view!.fields.map((f) => f.required)).toEqual([true, true, false, false]);
42
+ expect(view!.fields.map((f) => f.type)).toEqual(["text", "email", "select", "textarea"]);
43
+ expect(view!.fields.map((f) => f.label)).toEqual(["Name", "Email", "Subject", "Message"]);
44
+ });
45
+
46
+ it("pairs name and email, and runs every other field full width", () => {
47
+ expect(view!.fields.map((f) => f.colSpan)).toEqual([1, 1, 2, 2]);
48
+ });
49
+
50
+ it("gives the subject its five options as { value, label } pairs and its placeholder", () => {
51
+ const subject = view!.fields.find((f) => f.name === "subject")!;
52
+ expect(subject.options).toEqual(
53
+ CONTACT_FORM_SUBJECT_OPTIONS.map((value) => ({ value, label: value })),
54
+ );
55
+ expect(subject.placeholder).toBe("Choose a subject");
56
+ });
57
+
58
+ it("takes the terms statement and the step's cta", () => {
59
+ expect(view!.consentStatement).toBe(CONTACT_FORM_TERMS_STATEMENT);
60
+ expect(view!.submitLabel).toBe("Send message");
61
+ });
62
+ });
63
+
64
+ describe("contactFormFromConfig — an owner's edit (AC-3)", () => {
65
+ it("renders a relabelled subject and a sixth option", () => {
66
+ const config = seeded();
67
+ const subject = firstStep(config).fields.find((f) => f.name === "subject")!;
68
+ subject.copy.label = { en: "Topic" };
69
+ subject.options = [...subject.options!, { value: "Podcast", label: { en: "Podcast" } }];
70
+
71
+ const view = contactFormFromConfig(config, "en")!;
72
+ const rendered = view.fields.find((f) => f.name === "subject")!;
73
+ expect(rendered.label).toBe("Topic");
74
+ expect(rendered.options).toHaveLength(6);
75
+ });
76
+
77
+ it("is null when the step has no name field", () => {
78
+ const config = seeded();
79
+ const step = firstStep(config);
80
+ step.fields = step.fields.filter((f) => f.name !== "name");
81
+ expect(contactFormFromConfig(config, "en")).toBeNull();
82
+ });
83
+
84
+ it("is null when the step has no email field", () => {
85
+ const config = seeded();
86
+ const step = firstStep(config);
87
+ step.fields = step.fields.filter((f) => f.name !== "email");
88
+ expect(contactFormFromConfig(config, "en")).toBeNull();
89
+ });
90
+
91
+ it("is null when the first step is not a field step", () => {
92
+ const config = seeded();
93
+ config.steps = [
94
+ {
95
+ key: "segment",
96
+ type: "segmentation",
97
+ copy: { title: { en: "Who are you?" } },
98
+ questions: [{ name: "role", options: [{ value: "a", label: { en: "A" } }] }],
99
+ },
100
+ ...config.steps,
101
+ ];
102
+ expect(contactFormFromConfig(config, "en")).toBeNull();
103
+ });
104
+
105
+ it("renders neither an address field nor a second consent field", () => {
106
+ const config = seeded();
107
+ const step = firstStep(config);
108
+ step.fields = [
109
+ ...step.fields,
110
+ { name: "where", inputType: "address", copy: { label: { en: "Address" } } },
111
+ {
112
+ name: "marketing",
113
+ inputType: "consent",
114
+ purposes: ["marketing"],
115
+ copy: { label: { en: "Send me news." } },
116
+ },
117
+ ] as FieldStep["fields"];
118
+
119
+ const view = contactFormFromConfig(config, "en")!;
120
+ expect(view.fields.map((f) => f.name)).toEqual(["name", "email", "subject", "message"]);
121
+ expect(view.consentStatement).toBe(CONTACT_FORM_TERMS_STATEMENT);
122
+ });
123
+
124
+ it("gives no statement when the step has no consent field", () => {
125
+ const config = seeded();
126
+ const step = firstStep(config);
127
+ step.fields = step.fields.filter((f) => f.inputType !== "consent");
128
+ expect(contactFormFromConfig(config, "en")!.consentStatement).toBeUndefined();
129
+ });
130
+
131
+ it("resolves copy for the active locale", () => {
132
+ const config = seeded();
133
+ firstStep(config).fields[0]!.copy.label = { en: "Name", fr: "Nom" };
134
+
135
+ expect(contactFormFromConfig(config, "fr")!.fields[0]!.label).toBe("Nom");
136
+ expect(contactFormFromConfig(config, "en")!.fields[0]!.label).toBe("Name");
137
+ });
138
+ });
@@ -4,6 +4,8 @@
4
4
  * the block to fill.
5
5
  */
6
6
  import messages from "../../../../messages/en.json";
7
+ import { CONTACT_FORM_TERMS_STATEMENT } from "@working-theory/validation";
8
+
7
9
  import { contactFormText } from "../contact-form-text";
8
10
 
9
11
  /** A translator over the English catalog that interpolates `{name}` values, as ICU does. */
@@ -24,9 +26,24 @@ describe("contactFormText", () => {
24
26
  success: messages.contact.successMessage,
25
27
  genericError: messages.contact.genericError,
26
28
  consentStatement: messages.contact.consentStatement,
29
+ consentRequired: "Please accept the terms to send your message.",
27
30
  unavailableHeading: messages.contact.unavailableHeading,
28
31
  unavailableBody: "Email us at {email} and we'll reply.",
29
32
  unavailableNoEmail: messages.contact.unavailableNoEmail,
30
33
  });
31
34
  });
32
35
  });
36
+
37
+ describe("the contact catalog (#8661 D9)", () => {
38
+ it("states the three-link terms statement the Contact form magnet seeds", () => {
39
+ expect(messages.contact.consentStatement).toBe(CONTACT_FORM_TERMS_STATEMENT);
40
+ });
41
+
42
+ it("carries a workingAgreement namespace in the terms namespace's shape", () => {
43
+ expect(messages.workingAgreement).toEqual({
44
+ heading: "Working Agreement",
45
+ emptyState: "Working agreement content coming soon.",
46
+ });
47
+ expect(Object.keys(messages.workingAgreement)).toEqual(Object.keys(messages.terms));
48
+ });
49
+ });
@@ -0,0 +1,386 @@
1
+ /**
2
+ * contact/page.tsx over the Contact form magnet (#8661 D6) — AC-3 and AC-5.
3
+ *
4
+ * Unlike `page-renderer-migration.test.tsx`, this suite renders the REAL
5
+ * `LeadCaptureForm` and binds the REAL `captureLeadAction`, so the hidden
6
+ * `source` input, the fields and a submit are all visible. Mocked
7
+ * boundaries: `@working-theory/api-client` (`flows.get`, `leads.captureStatus`,
8
+ * `leads.submit`), `next/headers`, `next-intl/server` (over the real English
9
+ * catalog), the `_site.mdx` contact-email read, and the page-renderer's
10
+ * content loaders (injected).
11
+ */
12
+ import React from "react";
13
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
14
+ import {
15
+ CONTACT_FORM_TERMS_STATEMENT,
16
+ contactFormFlowConfig,
17
+ type FlowConfig,
18
+ } from "@working-theory/validation";
19
+
20
+ jest.mock("next/navigation", () => ({
21
+ __esModule: true,
22
+ notFound: jest.fn(() => {
23
+ throw new Error("NEXT_NOT_FOUND");
24
+ }),
25
+ }));
26
+
27
+ jest.mock("next-mdx-remote/rsc", () => ({
28
+ __esModule: true,
29
+ MDXRemote: ({ source }: { source: string }) =>
30
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
31
+ }));
32
+
33
+ jest.mock("@working-theory/api-client", () => ({
34
+ flows: { get: jest.fn() },
35
+ leads: { captureStatus: jest.fn(), submit: jest.fn() },
36
+ visitorForwardHeaders: () => undefined,
37
+ }));
38
+
39
+ jest.mock("next/headers", () => ({
40
+ __esModule: true,
41
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
42
+ cookies: jest.fn().mockResolvedValue({ get: () => undefined }),
43
+ }));
44
+
45
+ // The real English catalog, so the page's own strings render as they ship.
46
+ jest.mock("next-intl/server", () => {
47
+ const messages = jest.requireActual("../../../../messages/en.json") as Record<
48
+ string,
49
+ Record<string, unknown>
50
+ >;
51
+ return {
52
+ getTranslations: async (arg: string | { namespace: string }) => {
53
+ const ns = messages[typeof arg === "string" ? arg : arg.namespace] ?? {};
54
+ return (key: string, values?: Record<string, string>) =>
55
+ String(ns[key] ?? key).replace(/\{(\w+)\}/g, (m, name: string) => values?.[name] ?? m);
56
+ },
57
+ };
58
+ });
59
+
60
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the real read is a node:fs frontmatter parse of content/_site.mdx; narrowest possible scope
61
+ jest.mock("../site-contact-email", () => ({
62
+ readSiteContactEmail: jest.fn().mockReturnValue(undefined),
63
+ }));
64
+
65
+ import { flows, leads } from "@working-theory/api-client";
66
+ import { localeHref } from "@working-theory/ui";
67
+
68
+ import { contactFormFromConfig } from "../contact-form-magnet";
69
+ import * as pageModule from "../page";
70
+ import ContactPage from "../page";
71
+
72
+ const mockFlowsGet = flows.get as jest.Mock;
73
+ const mockCaptureStatus = leads.captureStatus as jest.Mock;
74
+ const mockSubmit = leads.submit as jest.Mock;
75
+
76
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
77
+
78
+ const source = {
79
+ frontmatter: {
80
+ type: "ContactPage" as const,
81
+ slug: "contact",
82
+ title: "Contact Us",
83
+ description: "Get in touch.",
84
+ },
85
+ body: "",
86
+ };
87
+
88
+ /** The seeded config with its subject relabelled. */
89
+ function configWithSubjectLabel(label: string): FlowConfig {
90
+ const config = JSON.parse(JSON.stringify(contactFormFlowConfig)) as FlowConfig;
91
+ const subject = (config.steps[0] as FieldStep).fields.find((f) => f.name === "subject")!;
92
+ subject.copy.label = { en: label };
93
+ return config;
94
+ }
95
+
96
+ const served = (config: FlowConfig) => ({ ok: true, status: 200, body: config });
97
+
98
+ async function renderPage(sections: unknown[] = []) {
99
+ const element = await ContactPage({
100
+ params: Promise.resolve({ locale: "en" }),
101
+ getMdxSource: jest.fn().mockResolvedValue(source),
102
+ getPageSections: jest.fn().mockResolvedValue(sections),
103
+ });
104
+ return render(element);
105
+ }
106
+
107
+ function sourceInput(): HTMLInputElement | null {
108
+ return document.querySelector<HTMLInputElement>("input[name='source']");
109
+ }
110
+
111
+ function fieldNames(): string[] {
112
+ return Array.from(
113
+ screen.getByTestId("lead-capture-form").querySelectorAll<HTMLElement>("[data-testid^='field-']"),
114
+ ).map((el) => el.dataset.testid!.slice("field-".length));
115
+ }
116
+
117
+ async function tickAndSubmit(values: Record<string, string>) {
118
+ for (const [name, value] of Object.entries(values)) {
119
+ fireEvent.change(screen.getByTestId(`field-${name}`), { target: { value } });
120
+ }
121
+ fireEvent.click(screen.getByTestId("contact-consent"));
122
+ await act(async () => {
123
+ fireEvent.submit(screen.getByTestId("lead-capture-form"));
124
+ });
125
+ }
126
+
127
+ // #8731 — the page shares its two hub reads for a few seconds, per module
128
+ // instance. Each test starts an hour later so none sees another's read.
129
+ let clock = 1_800_000_000_000;
130
+ const nowSpy = jest.spyOn(Date, "now");
131
+
132
+ beforeEach(() => {
133
+ jest.clearAllMocks();
134
+ clock += 60 * 60 * 1000;
135
+ nowSpy.mockImplementation(() => clock);
136
+ mockCaptureStatus.mockResolvedValue({
137
+ ok: true,
138
+ status: 200,
139
+ body: { ok: true, data: { contact: true, newsletter: false } },
140
+ });
141
+ mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
142
+ });
143
+
144
+ describe("contact/page — rendering (AC-3)", () => {
145
+ it("is rendered on every request, never ISR", () => {
146
+ expect(pageModule.dynamic).toBe("force-dynamic");
147
+ expect("revalidate" in pageModule).toBe(false);
148
+ });
149
+
150
+ it("renders the served magnet's fields, and stamps the post flow:contact", async () => {
151
+ mockFlowsGet.mockResolvedValue(served(configWithSubjectLabel("Topic")));
152
+
153
+ await renderPage();
154
+
155
+ expect(mockFlowsGet).toHaveBeenCalledWith("contact");
156
+ expect(fieldNames()).toEqual(["name", "email", "subject", "message"]);
157
+ expect(screen.getByTestId("lead-capture-form")).toHaveTextContent("Topic");
158
+ expect(sourceInput()).toHaveAttribute("type", "hidden");
159
+ expect(sourceInput()!.value).toBe("flow:contact");
160
+ });
161
+
162
+ it("the magnet wins over a content section's declared fields", async () => {
163
+ mockFlowsGet.mockResolvedValue(served(configWithSubjectLabel("Topic")));
164
+
165
+ await renderPage([
166
+ {
167
+ id: "contact-form",
168
+ type: "contact-form",
169
+ data: { fields: [{ name: "company", label: "Company", type: "text" }] },
170
+ },
171
+ ]);
172
+
173
+ expect(fieldNames()).toEqual(["name", "email", "subject", "message"]);
174
+ expect(screen.queryByTestId("field-company")).toBeNull();
175
+ });
176
+
177
+ it("a burst of views makes one hub read of each kind, not one per view (#8731)", async () => {
178
+ mockFlowsGet.mockResolvedValue(served(configWithSubjectLabel("Topic")));
179
+ for (let i = 0; i < 25; i += 1) {
180
+ const view = await renderPage();
181
+ expect(screen.getByTestId("lead-capture-form")).toHaveTextContent("Topic");
182
+ view.unmount();
183
+ }
184
+ expect(mockFlowsGet).toHaveBeenCalledTimes(1);
185
+ expect(mockCaptureStatus).toHaveBeenCalledTimes(1);
186
+ });
187
+
188
+ it("concurrent views share one in-flight read of each (#8731)", async () => {
189
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
190
+ await Promise.all([renderPage(), renderPage(), renderPage()]);
191
+ expect(mockFlowsGet).toHaveBeenCalledTimes(1);
192
+ expect(mockCaptureStatus).toHaveBeenCalledTimes(1);
193
+ });
194
+
195
+ it("an Admin edit shows once the shared read expires (#8731)", async () => {
196
+ mockFlowsGet.mockResolvedValueOnce(served(configWithSubjectLabel("Topic")));
197
+ const first = await renderPage();
198
+ expect(screen.getByTestId("lead-capture-form")).toHaveTextContent("Topic");
199
+ first.unmount();
200
+
201
+ mockFlowsGet.mockResolvedValueOnce(served(configWithSubjectLabel("Reason")));
202
+ clock += 60_000;
203
+ await renderPage();
204
+ const form = screen.getByTestId("lead-capture-form");
205
+ expect(form).toHaveTextContent("Reason");
206
+ expect(form).not.toHaveTextContent("Topic");
207
+ expect(mockFlowsGet).toHaveBeenCalledTimes(2);
208
+ });
209
+
210
+ it("a failed read is shared too, so an unreachable hub is not retried per view (#8731)", async () => {
211
+ mockFlowsGet.mockRejectedValue(new Error("hub down"));
212
+ mockCaptureStatus.mockRejectedValue(new Error("hub down"));
213
+ for (let i = 0; i < 5; i += 1) {
214
+ const view = await renderPage();
215
+ expect(screen.getByTestId("lead-capture-form")).toBeInTheDocument();
216
+ view.unmount();
217
+ }
218
+ expect(mockFlowsGet).toHaveBeenCalledTimes(1);
219
+ expect(mockCaptureStatus).toHaveBeenCalledTimes(1);
220
+ });
221
+
222
+ it("links Terms, Privacy Policy and Working Agreement in the statement", async () => {
223
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
224
+
225
+ await renderPage();
226
+
227
+ expect(screen.getByTestId("consent-policy-link-terms")).toHaveAttribute(
228
+ "href",
229
+ localeHref("en", "/terms"),
230
+ );
231
+ expect(screen.getByTestId("consent-policy-link-privacy")).toHaveAttribute(
232
+ "href",
233
+ localeHref("en", "/privacy"),
234
+ );
235
+ expect(screen.getByTestId("consent-policy-link-working_agreement")).toHaveAttribute(
236
+ "href",
237
+ localeHref("en", "/working-agreement"),
238
+ );
239
+ });
240
+
241
+ it("a magnet-driven post reaches leads.submit as flow:contact with its subject", async () => {
242
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
243
+
244
+ await renderPage();
245
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
246
+
247
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
248
+ expect(mockSubmit.mock.calls[0][0]).toMatchObject({ source: "flow:contact" });
249
+ });
250
+ });
251
+
252
+ describe("contact/page — fallback (AC-5)", () => {
253
+ const builtIn = contactFormFromConfig(contactFormFlowConfig, "en")!;
254
+
255
+ it.each([
256
+ ["answers 404", () => mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} })],
257
+ ["rejects", () => mockFlowsGet.mockRejectedValue(new Error("ECONNREFUSED"))],
258
+ [
259
+ "serves a config the form cannot use",
260
+ () => {
261
+ const config = JSON.parse(JSON.stringify(contactFormFlowConfig)) as FlowConfig;
262
+ const step = config.steps[0] as FieldStep;
263
+ step.fields = step.fields.filter((f) => f.name !== "name");
264
+ mockFlowsGet.mockResolvedValue(served(config));
265
+ },
266
+ ],
267
+ ])("flows.get %s → the built-in form, posted as web", async (_label, arrange) => {
268
+ arrange();
269
+
270
+ await renderPage();
271
+
272
+ const form = screen.getByTestId("lead-capture-form");
273
+ expect(fieldNames()).toEqual(builtIn.fields.map((f) => f.name));
274
+ for (const field of builtIn.fields) expect(form).toHaveTextContent(field.label);
275
+ const subject = builtIn.fields.find((f) => f.name === "subject")!;
276
+ const native = form.querySelector("select");
277
+ if (native) {
278
+ const values = Array.from(native.querySelectorAll("option"))
279
+ .map((o) => o.value)
280
+ .filter(Boolean);
281
+ expect(values).toEqual(
282
+ subject.options!.map((o) => (typeof o === "string" ? o : o.value)),
283
+ );
284
+ }
285
+ expect(
286
+ document.getElementById("lead-capture-consent-statement")!.textContent,
287
+ ).toBe(CONTACT_FORM_TERMS_STATEMENT.replace(/\[([^\]]+)\]\([a-z_]+\)/g, "$1"));
288
+ expect(sourceInput()).toBeNull();
289
+
290
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
291
+
292
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
293
+ expect(mockSubmit.mock.calls[0][0]).toMatchObject({ source: "contact-form" });
294
+ });
295
+
296
+ it("with no magnet, a section's declared fields still apply", async () => {
297
+ mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
298
+
299
+ await renderPage([
300
+ {
301
+ id: "contact-form",
302
+ type: "contact-form",
303
+ data: {
304
+ fields: [
305
+ { name: "name", label: "Name", type: "text" },
306
+ { name: "email", label: "Email", type: "email" },
307
+ { name: "company", label: "Company", type: "text" },
308
+ ],
309
+ submitLabel: "Request a demo",
310
+ },
311
+ },
312
+ ]);
313
+
314
+ expect(fieldNames()).toEqual(["name", "email", "company"]);
315
+ expect(screen.getByTestId("contact-submit")).toHaveTextContent("Request a demo");
316
+ });
317
+
318
+ it("the built-in form takes its labels from the contact catalog and the section's copy", async () => {
319
+ mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
320
+ const messages = jest.requireActual("../../../../messages/en.json") as {
321
+ contact: Record<string, string>;
322
+ };
323
+
324
+ await renderPage([
325
+ {
326
+ id: "contact-form",
327
+ type: "contact-form",
328
+ data: { fields: [], submitLabel: "Say hello", successMessage: "Got it, thanks." },
329
+ },
330
+ ]);
331
+
332
+ const form = screen.getByTestId("lead-capture-form");
333
+ expect(form).toHaveTextContent(messages.contact.nameLabel!);
334
+ expect(form).toHaveTextContent(messages.contact.messageLabel!);
335
+ expect(screen.getByTestId("contact-submit")).toHaveTextContent("Say hello");
336
+
337
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
338
+ await waitFor(() => expect(document.body).toHaveTextContent("Got it, thanks."));
339
+ });
340
+
341
+ it("a magnet's submit label wins, and the section's success message still applies", async () => {
342
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
343
+
344
+ await renderPage([
345
+ {
346
+ id: "contact-form",
347
+ type: "contact-form",
348
+ data: { fields: [], submitLabel: "Say hello", successMessage: "Got it, thanks." },
349
+ },
350
+ ]);
351
+
352
+ const form = screen.getByTestId("lead-capture-form");
353
+ expect(screen.getByTestId("contact-submit")).toHaveTextContent(
354
+ contactFormFromConfig(contactFormFlowConfig, "en")!.submitLabel!,
355
+ );
356
+
357
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
358
+ await waitFor(() => expect(document.body).toHaveTextContent("Got it, thanks."));
359
+ });
360
+ });
361
+
362
+ describe("contact/page — the column look (#8661 D12)", () => {
363
+ it("a column section gets the stacked look and an enabled Send", async () => {
364
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
365
+
366
+ await renderPage([
367
+ {
368
+ id: "contact",
369
+ type: "contact-form",
370
+ data: { variant: "column", headline: "Contact Us", description: "Say hello.", fields: [] },
371
+ },
372
+ ]);
373
+
374
+ expect(screen.getByTestId("lead-capture-form")).toHaveAttribute("data-look", "column");
375
+ expect(screen.getByTestId("contact-submit")).toBeEnabled();
376
+ });
377
+
378
+ it("a standard page keeps Send disabled until ticked", async () => {
379
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
380
+
381
+ await renderPage();
382
+
383
+ expect(screen.getByTestId("lead-capture-form")).not.toHaveAttribute("data-look");
384
+ expect(screen.getByTestId("contact-submit")).toBeDisabled();
385
+ });
386
+ });