create-ailk 0.19.1 → 0.21.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 (199) hide show
  1. package/README.md +84 -7
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/cli.js +35 -0
  7. package/dist/copy.d.ts +41 -0
  8. package/dist/copy.js +87 -21
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/lib/design-checks.d.ts +6 -1
  12. package/dist/lib/design-checks.js +23 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  14. package/dist/lib/fetch-template-snapshot.js +205 -0
  15. package/dist/lib/merge-three-way.d.ts +31 -0
  16. package/dist/lib/merge-three-way.js +65 -0
  17. package/dist/module-architecture.js +55 -2
  18. package/dist/programmatic.d.ts +7 -1
  19. package/dist/programmatic.js +76 -22
  20. package/dist/site-roster.d.ts +4 -2
  21. package/dist/site-roster.js +7 -5
  22. package/dist/spoke-checkout.d.ts +7 -0
  23. package/dist/spoke-checkout.js +8 -1
  24. package/dist/starter-grid.js +16 -0
  25. package/dist/surfaces.js +78 -2
  26. package/dist/sync-routes.d.ts +28 -0
  27. package/dist/sync-routes.js +123 -1
  28. package/dist/template-record.d.ts +59 -0
  29. package/dist/template-record.js +183 -0
  30. package/dist/update.d.ts +53 -0
  31. package/dist/update.js +606 -0
  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/.claude/cloudparity-repo-hooks.json +9 -0
  51. package/templates/.claude/hooks/architecture-check.sh +8 -0
  52. package/templates/.claude/rules/loop-contract.md +129 -0
  53. package/templates/.claude/settings.json +3 -2
  54. package/templates/.env.example +13 -0
  55. package/templates/CLAUDE.md +1 -1
  56. package/templates/CONVENTIONS.md +3 -2
  57. package/templates/README.md +26 -25
  58. package/templates/_gitignore +162 -0
  59. package/templates/apps/api/CLAUDE.md +18 -10
  60. package/templates/apps/api/jest.config.cjs +1 -0
  61. package/templates/apps/api/package.json +2 -1
  62. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  63. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  64. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  65. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  66. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  67. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  68. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  69. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  70. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  71. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  72. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  74. package/templates/apps/api/src/middleware/auth.ts +8 -4
  75. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  76. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  77. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  78. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  79. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  80. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  81. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  82. package/templates/apps/api/src/server.ts +14 -2
  83. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  84. package/templates/apps/api/src/services/audit-log.ts +72 -10
  85. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  86. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  87. package/templates/apps/api/src/services/project-listings.ts +6 -1
  88. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  89. package/templates/apps/api/vercel.json +1 -1
  90. package/templates/apps/mcp/CLAUDE.md +19 -7
  91. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
  92. package/templates/apps/mcp/jest.config.cjs +1 -0
  93. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  94. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  95. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  96. package/templates/apps/web/CLAUDE.md +26 -1
  97. package/templates/apps/web/_gitignore +6 -0
  98. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  99. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  100. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  111. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  115. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  118. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  124. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  125. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  129. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  132. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  133. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  134. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  135. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  137. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  138. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  139. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  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 +347 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -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 +48 -4
  148. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  149. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  150. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  151. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/layout.tsx +46 -4
  154. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  155. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  156. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  157. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  158. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  160. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  161. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  162. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  163. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  164. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  165. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  166. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  167. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  168. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  169. package/templates/apps/web/jest.config.cjs +4 -0
  170. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  171. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  172. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  173. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
  174. package/templates/apps/web/lib/account-config.ts +31 -16
  175. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  176. package/templates/apps/web/lib/deck-page.ts +186 -0
  177. package/templates/apps/web/lib/relay.ts +20 -0
  178. package/templates/apps/web/messages/en.json +83 -9
  179. package/templates/apps/web/next.config.mjs +8 -2
  180. package/templates/database/CHANGELOG.md +48 -0
  181. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  182. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/__tests__/seed.test.ts +39 -2
  186. package/templates/database/_gitignore +8 -0
  187. package/templates/database/inbox/schema.prisma +38 -12
  188. package/templates/database/jest.config.cjs +1 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/database/seed.ts +29 -0
  196. package/templates/project.yaml +6 -3
  197. package/templates/tools/jest/pin-timezone.cjs +34 -0
  198. package/versions.json +35 -35
  199. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,347 @@
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
+ beforeEach(() => {
128
+ jest.clearAllMocks();
129
+ mockCaptureStatus.mockResolvedValue({
130
+ ok: true,
131
+ status: 200,
132
+ body: { ok: true, data: { contact: true, newsletter: false } },
133
+ });
134
+ mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
135
+ });
136
+
137
+ describe("contact/page — rendering (AC-3)", () => {
138
+ it("is rendered on every request, never ISR", () => {
139
+ expect(pageModule.dynamic).toBe("force-dynamic");
140
+ expect("revalidate" in pageModule).toBe(false);
141
+ });
142
+
143
+ it("renders the served magnet's fields, and stamps the post flow:contact", async () => {
144
+ mockFlowsGet.mockResolvedValue(served(configWithSubjectLabel("Topic")));
145
+
146
+ await renderPage();
147
+
148
+ expect(mockFlowsGet).toHaveBeenCalledWith("contact");
149
+ expect(fieldNames()).toEqual(["name", "email", "subject", "message"]);
150
+ expect(screen.getByTestId("lead-capture-form")).toHaveTextContent("Topic");
151
+ expect(sourceInput()).toHaveAttribute("type", "hidden");
152
+ expect(sourceInput()!.value).toBe("flow:contact");
153
+ });
154
+
155
+ it("the magnet wins over a content section's declared fields", async () => {
156
+ mockFlowsGet.mockResolvedValue(served(configWithSubjectLabel("Topic")));
157
+
158
+ await renderPage([
159
+ {
160
+ id: "contact-form",
161
+ type: "contact-form",
162
+ data: { fields: [{ name: "company", label: "Company", type: "text" }] },
163
+ },
164
+ ]);
165
+
166
+ expect(fieldNames()).toEqual(["name", "email", "subject", "message"]);
167
+ expect(screen.queryByTestId("field-company")).toBeNull();
168
+ });
169
+
170
+ it("two requests with two configs render two labels (no caching)", async () => {
171
+ mockFlowsGet.mockResolvedValueOnce(served(configWithSubjectLabel("Topic")));
172
+ const first = await renderPage();
173
+ expect(screen.getByTestId("lead-capture-form")).toHaveTextContent("Topic");
174
+ first.unmount();
175
+
176
+ mockFlowsGet.mockResolvedValueOnce(served(configWithSubjectLabel("Reason")));
177
+ await renderPage();
178
+ const form = screen.getByTestId("lead-capture-form");
179
+ expect(form).toHaveTextContent("Reason");
180
+ expect(form).not.toHaveTextContent("Topic");
181
+ });
182
+
183
+ it("links Terms, Privacy Policy and Working Agreement in the statement", async () => {
184
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
185
+
186
+ await renderPage();
187
+
188
+ expect(screen.getByTestId("consent-policy-link-terms")).toHaveAttribute(
189
+ "href",
190
+ localeHref("en", "/terms"),
191
+ );
192
+ expect(screen.getByTestId("consent-policy-link-privacy")).toHaveAttribute(
193
+ "href",
194
+ localeHref("en", "/privacy"),
195
+ );
196
+ expect(screen.getByTestId("consent-policy-link-working_agreement")).toHaveAttribute(
197
+ "href",
198
+ localeHref("en", "/working-agreement"),
199
+ );
200
+ });
201
+
202
+ it("a magnet-driven post reaches leads.submit as flow:contact with its subject", async () => {
203
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
204
+
205
+ await renderPage();
206
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
207
+
208
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
209
+ expect(mockSubmit.mock.calls[0][0]).toMatchObject({ source: "flow:contact" });
210
+ });
211
+ });
212
+
213
+ describe("contact/page — fallback (AC-5)", () => {
214
+ const builtIn = contactFormFromConfig(contactFormFlowConfig, "en")!;
215
+
216
+ it.each([
217
+ ["answers 404", () => mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} })],
218
+ ["rejects", () => mockFlowsGet.mockRejectedValue(new Error("ECONNREFUSED"))],
219
+ [
220
+ "serves a config the form cannot use",
221
+ () => {
222
+ const config = JSON.parse(JSON.stringify(contactFormFlowConfig)) as FlowConfig;
223
+ const step = config.steps[0] as FieldStep;
224
+ step.fields = step.fields.filter((f) => f.name !== "name");
225
+ mockFlowsGet.mockResolvedValue(served(config));
226
+ },
227
+ ],
228
+ ])("flows.get %s → the built-in form, posted as web", async (_label, arrange) => {
229
+ arrange();
230
+
231
+ await renderPage();
232
+
233
+ const form = screen.getByTestId("lead-capture-form");
234
+ expect(fieldNames()).toEqual(builtIn.fields.map((f) => f.name));
235
+ for (const field of builtIn.fields) expect(form).toHaveTextContent(field.label);
236
+ const subject = builtIn.fields.find((f) => f.name === "subject")!;
237
+ const native = form.querySelector("select");
238
+ if (native) {
239
+ const values = Array.from(native.querySelectorAll("option"))
240
+ .map((o) => o.value)
241
+ .filter(Boolean);
242
+ expect(values).toEqual(
243
+ subject.options!.map((o) => (typeof o === "string" ? o : o.value)),
244
+ );
245
+ }
246
+ expect(
247
+ document.getElementById("lead-capture-consent-statement")!.textContent,
248
+ ).toBe(CONTACT_FORM_TERMS_STATEMENT.replace(/\[([^\]]+)\]\([a-z_]+\)/g, "$1"));
249
+ expect(sourceInput()).toBeNull();
250
+
251
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
252
+
253
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
254
+ expect(mockSubmit.mock.calls[0][0]).toMatchObject({ source: "web" });
255
+ });
256
+
257
+ it("with no magnet, a section's declared fields still apply", async () => {
258
+ mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
259
+
260
+ await renderPage([
261
+ {
262
+ id: "contact-form",
263
+ type: "contact-form",
264
+ data: {
265
+ fields: [
266
+ { name: "name", label: "Name", type: "text" },
267
+ { name: "email", label: "Email", type: "email" },
268
+ { name: "company", label: "Company", type: "text" },
269
+ ],
270
+ submitLabel: "Request a demo",
271
+ },
272
+ },
273
+ ]);
274
+
275
+ expect(fieldNames()).toEqual(["name", "email", "company"]);
276
+ expect(screen.getByTestId("contact-submit")).toHaveTextContent("Request a demo");
277
+ });
278
+
279
+ it("the built-in form takes its labels from the contact catalog and the section's copy", async () => {
280
+ mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
281
+ const messages = jest.requireActual("../../../../messages/en.json") as {
282
+ contact: Record<string, string>;
283
+ };
284
+
285
+ await renderPage([
286
+ {
287
+ id: "contact-form",
288
+ type: "contact-form",
289
+ data: { fields: [], submitLabel: "Say hello", successMessage: "Got it, thanks." },
290
+ },
291
+ ]);
292
+
293
+ const form = screen.getByTestId("lead-capture-form");
294
+ expect(form).toHaveTextContent(messages.contact.nameLabel!);
295
+ expect(form).toHaveTextContent(messages.contact.messageLabel!);
296
+ expect(screen.getByTestId("contact-submit")).toHaveTextContent("Say hello");
297
+
298
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
299
+ await waitFor(() => expect(document.body).toHaveTextContent("Got it, thanks."));
300
+ });
301
+
302
+ it("a magnet's submit label wins, and the section's success message still applies", async () => {
303
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
304
+
305
+ await renderPage([
306
+ {
307
+ id: "contact-form",
308
+ type: "contact-form",
309
+ data: { fields: [], submitLabel: "Say hello", successMessage: "Got it, thanks." },
310
+ },
311
+ ]);
312
+
313
+ const form = screen.getByTestId("lead-capture-form");
314
+ expect(screen.getByTestId("contact-submit")).toHaveTextContent(
315
+ contactFormFromConfig(contactFormFlowConfig, "en")!.submitLabel!,
316
+ );
317
+
318
+ await tickAndSubmit({ name: "Ada", email: "ada@example.com" });
319
+ await waitFor(() => expect(document.body).toHaveTextContent("Got it, thanks."));
320
+ });
321
+ });
322
+
323
+ describe("contact/page — the column look (#8661 D12)", () => {
324
+ it("a column section gets the stacked look and an enabled Send", async () => {
325
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
326
+
327
+ await renderPage([
328
+ {
329
+ id: "contact",
330
+ type: "contact-form",
331
+ data: { variant: "column", headline: "Contact Us", description: "Say hello.", fields: [] },
332
+ },
333
+ ]);
334
+
335
+ expect(screen.getByTestId("lead-capture-form")).toHaveAttribute("data-look", "column");
336
+ expect(screen.getByTestId("contact-submit")).toBeEnabled();
337
+ });
338
+
339
+ it("a standard page keeps Send disabled until ticked", async () => {
340
+ mockFlowsGet.mockResolvedValue(served(contactFormFlowConfig));
341
+
342
+ await renderPage();
343
+
344
+ expect(screen.getByTestId("lead-capture-form")).not.toHaveAttribute("data-look");
345
+ expect(screen.getByTestId("contact-submit")).toBeDisabled();
346
+ });
347
+ });
@@ -51,6 +51,8 @@ const VALID_FIELDS = {
51
51
  name: "Jane Smith",
52
52
  email: "jane@example.com",
53
53
  message: "I am interested in your services.",
54
+ // #8661 D8 — the terms box is checked on the server too.
55
+ consent: "true",
54
56
  };
55
57
 
56
58
  const INITIAL_STATE = null;
@@ -369,3 +371,74 @@ describe("captureLeadAction — visitor forwarding (#7394)", () => {
369
371
  expect(mockSubmit.mock.calls[0]).toHaveLength(1);
370
372
  });
371
373
  });
374
+
375
+ // ─── #8661 D8 — the terms box and the source stamp ──────────────────────────
376
+
377
+ describe("captureLeadAction — consent and source (#8661 D8)", () => {
378
+ const { consent: _consent, ...UNTICKED } = VALID_FIELDS;
379
+
380
+ it("no consent → a consent field error, and nothing is sent", async () => {
381
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(UNTICKED));
382
+
383
+ expect(result).toEqual({
384
+ kind: "error",
385
+ fieldErrors: { consent: expect.any(String) },
386
+ });
387
+ expect(mockSubmit).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it("no consent and no name → both errors", async () => {
391
+ const result = await captureLeadAction(
392
+ INITIAL_STATE,
393
+ makeFormData({ ...UNTICKED, name: "" }),
394
+ );
395
+
396
+ expect(result).toMatchObject({ kind: "error" });
397
+ const fieldErrors = (result as { fieldErrors: Record<string, string> }).fieldErrors;
398
+ expect(Object.keys(fieldErrors).sort()).toEqual(["consent", "name"]);
399
+ expect(mockSubmit).not.toHaveBeenCalled();
400
+ });
401
+
402
+ it("a ticked flow:contact post carries the source and the subject", async () => {
403
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
404
+
405
+ const result = await captureLeadAction(
406
+ INITIAL_STATE,
407
+ makeFormData({ ...VALID_FIELDS, source: "flow:contact", subject: "Support" }),
408
+ );
409
+
410
+ expect(result).toEqual({ kind: "success" });
411
+ expect(mockSubmit).toHaveBeenCalledTimes(1);
412
+ const body = mockSubmit.mock.calls[0][0] as {
413
+ source: string;
414
+ payload: Record<string, unknown>;
415
+ };
416
+ expect(body.source).toBe("flow:contact");
417
+ expect(body.payload.subject).toBe("Support");
418
+ });
419
+
420
+ it.each(["flow:other", "flow:onboarding", "admin", "WEB"])(
421
+ "an unknown source (%s) is sent as web",
422
+ async (source) => {
423
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
424
+
425
+ await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, source }));
426
+
427
+ expect((mockSubmit.mock.calls[0][0] as { source: string }).source).toBe("web");
428
+ },
429
+ );
430
+
431
+ it("the conversational intake submits with no consent field", async () => {
432
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
433
+
434
+ const result = await captureLeadAction(
435
+ INITIAL_STATE,
436
+ makeFormData({ ...UNTICKED, source: "conversational-intake" }),
437
+ );
438
+
439
+ expect(result).toEqual({ kind: "success" });
440
+ expect((mockSubmit.mock.calls[0][0] as { source: string }).source).toBe(
441
+ "conversational-intake",
442
+ );
443
+ });
444
+ });
@@ -55,6 +55,7 @@ jest.mock("@working-theory/ui", () => ({
55
55
  LeadCaptureForm: (props: {
56
56
  fields?: Array<{ name: string }>;
57
57
  submitLabel?: string;
58
+ text?: { submit?: string };
58
59
  fallbackEmail?: string;
59
60
  termsHref: string;
60
61
  }) =>
@@ -69,7 +70,8 @@ jest.mock("@working-theory/ui", () => ({
69
70
  React.createElement(
70
71
  "button",
71
72
  { "data-testid": "contact-submit" },
72
- props.submitLabel ?? "Send",
73
+ // The real block falls back to its copy's submit label.
74
+ props.submitLabel ?? props.text?.submit ?? "Send",
73
75
  ),
74
76
  ),
75
77
  ContactFallback: (props: { email?: string; text?: { noEmail?: string } }) =>
@@ -181,14 +183,14 @@ describe("ContactPage — snapshot regression", () => {
181
183
  it("empty state renders deterministic HTML (renderPage empty-state + the form after it)", async () => {
182
184
  const { container } = await renderWith(null);
183
185
  expect(container.innerHTML).toMatchInlineSnapshot(
184
- `"<main id="main"><h1>Contact</h1><p data-testid="section-empty-state">Contact page content coming soon.</p></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
186
+ `"<main id="main"><h1>Contact</h1><p data-testid="section-empty-state">Contact page content coming soon.</p></main><div data-testid="lead-capture-form" data-fields="name,email,subject,message" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send message</button></div>"`,
185
187
  );
186
188
  });
187
189
 
188
190
  it("populated state renders deterministic HTML (renderPage sections + the form after it)", async () => {
189
191
  const { container } = await renderWith([]);
190
192
  expect(container.innerHTML).toMatchInlineSnapshot(
191
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><div><h1>Contact Us</h1><p data-testid="contact-description" class="mt-block-tight text-lead">Get in touch.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
193
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><div><h1>Contact Us</h1><p data-testid="contact-description" class="mt-block-tight text-lead">Get in touch.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="name,email,subject,message" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send message</button></div>"`,
192
194
  );
193
195
  });
194
196
  });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * contactFormFromConfig — a Contact form magnet's config, as the shared
3
+ * `LeadCaptureForm` block renders it (#8661 D6).
4
+ *
5
+ * Reads the config's first step, which must be a `field` step (a config that
6
+ * opens with any other step is not a contact form). Answers `null` unless that
7
+ * step holds fields named `name` and `email`, because `captureLeadAction` needs both;
8
+ * the page then falls back (section fields, else the built-in config).
9
+ *
10
+ * Each `text`, `email`, `tel`, `url`, `textarea` or `select` field is mapped,
11
+ * in order. `name` and `email` sit side by side (`colSpan: 1`, the built-in
12
+ * pairing); every other field runs full width. The step's first `consent`
13
+ * field is the terms statement (its label), and its `copy.cta` the submit
14
+ * label. Other `consent` fields and `address` fields are not rendered.
15
+ *
16
+ * Copy resolves with the one LocalizedString rule every flow surface shares
17
+ * (`resolveLocalizedString`). Pure: no fetch, no React.
18
+ */
19
+ import type { ContactFormField } from "@working-theory/ui";
20
+ import { resolveLocalizedString, type FlowConfig } from "@working-theory/validation";
21
+
22
+ export interface ContactFormView {
23
+ fields: ContactFormField[];
24
+ /** The step's first consent field's label — `[Label](key)` tokens intact. */
25
+ consentStatement?: string;
26
+ /** The step's `copy.cta`. */
27
+ submitLabel?: string;
28
+ }
29
+
30
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
31
+ type FieldDef = FieldStep["fields"][number];
32
+
33
+ const RENDERED_TYPES = ["text", "email", "tel", "url", "textarea", "select"] as const;
34
+ type RenderedType = (typeof RENDERED_TYPES)[number];
35
+
36
+ function isRendered(type: FieldDef["inputType"]): type is RenderedType {
37
+ return (RENDERED_TYPES as readonly string[]).includes(type);
38
+ }
39
+
40
+ export function contactFormFromConfig(
41
+ config: FlowConfig,
42
+ locale: string,
43
+ ): ContactFormView | null {
44
+ const step = config.steps[0];
45
+ if (step?.type !== "field") return null;
46
+ const names = new Set(step.fields.map((f) => f.name));
47
+ if (!names.has("name") || !names.has("email")) return null;
48
+
49
+ const resolve = (value: Record<string, string>): string =>
50
+ resolveLocalizedString(value, locale, config.defaultLocale);
51
+
52
+ const fields: ContactFormField[] = [];
53
+ for (const field of step.fields) {
54
+ if (!isRendered(field.inputType)) continue;
55
+ fields.push({
56
+ name: field.name,
57
+ type: field.inputType,
58
+ label: resolve(field.copy.label),
59
+ required: field.required ?? false,
60
+ ...(field.copy.placeholder !== undefined && {
61
+ placeholder: resolve(field.copy.placeholder),
62
+ }),
63
+ ...(field.copy.help !== undefined && { helpText: resolve(field.copy.help) }),
64
+ ...(field.options !== undefined && {
65
+ options: field.options.map((o) => ({ value: o.value, label: resolve(o.label) })),
66
+ }),
67
+ colSpan: field.name === "name" || field.name === "email" ? 1 : 2,
68
+ });
69
+ }
70
+
71
+ const consent = step.fields.find((f) => f.inputType === "consent");
72
+ return {
73
+ fields,
74
+ ...(consent !== undefined && { consentStatement: resolve(consent.copy.label) }),
75
+ ...(step.copy.cta !== undefined && { submitLabel: resolve(step.copy.cta) }),
76
+ };
77
+ }
@@ -20,6 +20,7 @@ type ContactTextKey =
20
20
  | "successMessage"
21
21
  | "genericError"
22
22
  | "consentStatement"
23
+ | "consentRequired"
23
24
  | "unavailableHeading"
24
25
  | "unavailableBody"
25
26
  | "unavailableNoEmail";
@@ -43,6 +44,7 @@ export function contactFormText(t: ContactTranslator): LeadCaptureFormText {
43
44
  success: t("successMessage"),
44
45
  genericError: t("genericError"),
45
46
  consentStatement: t("consentStatement"),
47
+ consentRequired: t("consentRequired"),
46
48
  unavailableHeading: t("unavailableHeading"),
47
49
  unavailableBody: t("unavailableBody", KEEP_EMAIL_PLACEHOLDER),
48
50
  unavailableNoEmail: t("unavailableNoEmail"),
@@ -36,6 +36,15 @@
36
36
  * (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → defaults
37
37
  * to `"web"`, unchanged from before.
38
38
  *
39
+ * Source allow-list and terms (#8661 D8): `/contact` stamps a submission it
40
+ * drew from the Contact form magnet with a hidden `source` of
41
+ * `"flow:contact"`, so it lands as an Inbox submission of that magnet. The
42
+ * field is visitor-controlled, so only `"web"`, `"conversational-intake"` and
43
+ * `"flow:contact"` are kept; anything else is sent as `"web"`. And the terms
44
+ * box is checked here, not only by the client: unless the source is the
45
+ * intake (which renders no box), a post without `consent=true` answers a
46
+ * `consent` field error, merged with the schema's, and nothing is sent.
47
+ *
39
48
  * Visitor forwarding (#7394): on a spoke whose API is a hub, the call
40
49
  * carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
41
50
  * (server-only, never `NEXT_PUBLIC_`) via `visitorForwardHeaders`, so the
@@ -47,8 +56,14 @@
47
56
 
48
57
  import { leads, visitorForwardHeaders } from "@working-theory/api-client";
49
58
  import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
50
- import { submitLeadInputSchema } from "@working-theory/validation";
51
- import { headers } from "next/headers";
59
+ import {
60
+ CONTACT_FORM_FLOW_SLUG,
61
+ flowLeadSource,
62
+ submitLeadInputSchema,
63
+ } from "@working-theory/validation";
64
+ import { cookies, headers } from "next/headers";
65
+
66
+ import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
52
67
 
53
68
  const GENERIC_ERROR = "Something went wrong, please try again.";
54
69
 
@@ -63,6 +78,16 @@ const RESERVED_FIELDS = new Set([
63
78
  "source",
64
79
  ]);
65
80
  const MAX_EXTRA_FIELDS = 20;
81
+
82
+ /** The only sources a form post may stamp (#8661 D8); anything else is `"web"`. */
83
+ const ALLOWED_SOURCES = new Set([
84
+ "web",
85
+ "conversational-intake",
86
+ flowLeadSource(CONTACT_FORM_FLOW_SLUG),
87
+ ]);
88
+ const INTAKE_SOURCE = "conversational-intake";
89
+ /** The server's terms-error text. The form block shows its own copy for this key. */
90
+ const CONSENT_REQUIRED = "Please accept the terms to send your message.";
66
91
  const MAX_EXTRA_FIELD_LENGTH = 2000;
67
92
 
68
93
  /**
@@ -110,15 +135,25 @@ export async function captureLeadAction(
110
135
  phone: formData.get("phone") ?? undefined,
111
136
  };
112
137
 
138
+ const posted = formData.get("source");
139
+ const source =
140
+ typeof posted === "string" && ALLOWED_SOURCES.has(posted) ? posted : "web";
141
+
142
+ const fieldErrors: Record<string, string> = {};
143
+ if (source !== INTAKE_SOURCE && formData.get("consent") !== "true") {
144
+ fieldErrors.consent = CONSENT_REQUIRED;
145
+ }
146
+
113
147
  const parsed = submitLeadInputSchema.safeParse(raw);
114
148
  if (!parsed.success) {
115
- const fieldErrors: Record<string, string> = {};
116
149
  for (const issue of parsed.error.issues) {
117
150
  const field = String(issue.path[0] ?? "form");
118
151
  if (!fieldErrors[field]) {
119
152
  fieldErrors[field] = issue.message;
120
153
  }
121
154
  }
155
+ }
156
+ if (!parsed.success || Object.keys(fieldErrors).length > 0) {
122
157
  return { kind: "error", fieldErrors };
123
158
  }
124
159
 
@@ -127,7 +162,6 @@ export async function captureLeadAction(
127
162
 
128
163
  const { name, email, message, phone } = parsed.data;
129
164
  const siteId = process.env.AILK_SITE_ID ?? "default";
130
- const source = (formData.get("source") as string | null) || "web";
131
165
  const body = {
132
166
  siteId,
133
167
  source,
@@ -151,6 +185,16 @@ export async function captureLeadAction(
151
185
  // No request scope to read — submit without forwarding.
152
186
  }
153
187
 
188
+ // #8438 D12 — the banner's visitor id, so a later form submit links its
189
+ // cookie choices to the client record this submit creates or resolves
190
+ // (D11). Absent or invalid → no header, exactly as before this unit.
191
+ try {
192
+ const visitorId = readConsentVisitor(await cookies());
193
+ if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
194
+ } catch {
195
+ // No request scope to read — submit with no visitor-link header.
196
+ }
197
+
154
198
  try {
155
199
  const res = init ? await leads.submit(body, init) : await leads.submit(body);
156
200
  if (!res.ok) {