create-ailk 0.20.0 → 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 (83) hide show
  1. package/README.md +21 -1
  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/design-home.d.ts +54 -11
  7. package/dist/design-home.js +160 -22
  8. package/dist/lib/design-checks.d.ts +6 -1
  9. package/dist/lib/design-checks.js +23 -2
  10. package/dist/programmatic.js +44 -20
  11. package/dist/site-roster.d.ts +4 -2
  12. package/dist/site-roster.js +7 -5
  13. package/dist/starter-grid.js +16 -0
  14. package/dist/surfaces.js +11 -0
  15. package/dist/sync-routes.d.ts +28 -0
  16. package/dist/sync-routes.js +123 -1
  17. package/dist/update.d.ts +6 -1
  18. package/dist/update.js +120 -18
  19. package/package.json +3 -3
  20. package/site-templates/_base/ailk-template.json +2 -2
  21. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  22. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  23. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  24. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  25. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  26. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  29. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  30. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  31. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  32. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  33. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  34. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  35. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  36. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  37. package/templates/README.md +26 -25
  38. package/templates/apps/api/CLAUDE.md +7 -3
  39. package/templates/apps/api/jest.config.cjs +1 -0
  40. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  41. package/templates/apps/api/src/services/project-listings.ts +6 -1
  42. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  43. package/templates/apps/mcp/jest.config.cjs +1 -0
  44. package/templates/apps/web/CLAUDE.md +26 -1
  45. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  46. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  47. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  48. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  50. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  51. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  52. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  54. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  55. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  56. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  57. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  58. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +35 -3
  59. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  60. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  61. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  62. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  63. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  64. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  65. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  68. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  69. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  70. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  71. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  72. package/templates/apps/web/jest.config.cjs +1 -0
  73. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  74. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +4 -2
  75. package/templates/apps/web/lib/deck-page.ts +186 -0
  76. package/templates/apps/web/messages/en.json +16 -5
  77. package/templates/database/CHANGELOG.md +9 -0
  78. package/templates/database/__tests__/seed.test.ts +39 -2
  79. package/templates/database/jest.config.cjs +1 -0
  80. package/templates/database/package.json +1 -1
  81. package/templates/database/seed.ts +29 -0
  82. package/templates/tools/jest/pin-timezone.cjs +34 -0
  83. package/versions.json +34 -34
@@ -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,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
  });