create-ailk 0.7.0 → 0.8.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 (169) hide show
  1. package/README.md +44 -10
  2. package/component-catalog.json +520 -5
  3. package/component-catalog.md +9 -6
  4. package/dist/cli.js +22 -24
  5. package/dist/lib/extract-module-bundle.js +5 -1
  6. package/dist/lib/module-license-gate.d.ts +79 -0
  7. package/dist/lib/module-license-gate.js +160 -12
  8. package/dist/lib/module-tier.d.ts +45 -16
  9. package/dist/lib/module-tier.js +76 -0
  10. package/dist/lib/pro-callout.js +10 -2
  11. package/dist/parse-args.d.ts +78 -10
  12. package/dist/parse-args.js +204 -14
  13. package/dist/programmatic.d.ts +39 -9
  14. package/dist/programmatic.js +163 -25
  15. package/dist/site-roster.js +11 -6
  16. package/dist/starter-grid.d.ts +216 -0
  17. package/dist/starter-grid.js +449 -0
  18. package/dist/surfaces.d.ts +4 -4
  19. package/dist/surfaces.js +102 -43
  20. package/dist/template-manifest.d.ts +36 -17
  21. package/dist/template-manifest.js +121 -367
  22. package/dist/template-names.d.ts +14 -12
  23. package/dist/template-names.js +13 -17
  24. package/package.json +3 -3
  25. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -1
  26. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  27. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  28. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  29. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  30. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  32. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  33. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  34. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  35. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  36. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  37. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  38. package/site-templates/_base/content/en/UseCase/persona-1.mdx +69 -0
  39. package/site-templates/_base/content/en/UseCase/persona-2.mdx +69 -0
  40. package/site-templates/_base/content/en/UseCase/persona-3.mdx +69 -0
  41. package/site-templates/designs/standard/DESIGN.md +5 -0
  42. package/site-templates/marketing-site/DESIGN.md +5 -0
  43. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +89 -25
  44. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +35 -14
  45. package/site-templates/marketing-site-free/DESIGN.md +5 -0
  46. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +95 -29
  47. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +74 -44
  48. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  49. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  50. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +17 -29
  51. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -1
  52. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  53. package/site-templates/saas-product/DESIGN.md +5 -0
  54. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  55. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +96 -76
  56. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +302 -0
  57. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  58. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  59. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  60. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  61. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  62. package/site-templates/{simple-service → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  63. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  64. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  65. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  66. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  67. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  68. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +362 -0
  69. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +300 -0
  70. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  71. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  72. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  73. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  74. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  75. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  76. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +38 -0
  77. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +38 -0
  78. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +38 -0
  79. package/site-templates/shapes/organization-service/content/en/ServicesList/services.mdx +72 -0
  80. package/site-templates/shapes/organization-service/provenance.json +4 -0
  81. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +369 -0
  82. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +299 -0
  83. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  84. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  85. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  86. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  87. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  88. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +169 -0
  89. package/site-templates/shapes/person-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  90. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  91. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  92. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  93. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  94. package/site-templates/shapes/person-service/provenance.json +4 -0
  95. package/templates/.claude/skills/README.md +3 -2
  96. package/templates/.claude/skills/add-page/references/page-types.md +4 -0
  97. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  98. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  99. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  100. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +19 -28
  101. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  102. package/templates/DESIGN.md +5 -0
  103. package/templates/README.md +46 -38
  104. package/templates/apps/api/CLAUDE.md +3 -2
  105. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  106. package/templates/apps/api/src/config/index.ts +35 -16
  107. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  108. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  109. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  110. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  111. package/templates/apps/api/src/openapi/spec.ts +63 -12
  112. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  113. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  114. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  115. package/templates/apps/api/src/server.ts +7 -0
  116. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  117. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  118. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  119. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  120. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  121. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  122. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  123. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  124. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  125. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  126. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  127. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  128. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  129. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  130. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  131. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  132. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  133. package/templates/apps/web/app/[locale]/layout.tsx +37 -5
  134. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  135. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  136. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  137. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  138. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  139. package/templates/apps/web/app/[locale]/portfolio/page.tsx +49 -0
  140. package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +18 -3
  141. package/templates/apps/web/lib/nav-config.ts +48 -36
  142. package/templates/apps/web/lib/site-identity.ts +14 -0
  143. package/templates/apps/web/messages/en.json +28 -2
  144. package/templates/content/_site.mdx +4 -2
  145. package/templates/content/en/BlogList/blog.mdx +37 -16
  146. package/templates/content/en/ContactPage/contact.mdx +14 -44
  147. package/templates/content/en/HomePage/home.mdx +8 -16
  148. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  149. package/templates/content/en/UseCasesList/use-cases.mdx +6 -6
  150. package/templates/database/CHANGELOG.md +7 -0
  151. package/templates/database/package.json +1 -1
  152. package/versions.json +33 -33
  153. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  154. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  155. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  156. package/site-templates/simple-service/DESIGN.md +0 -162
  157. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  158. package/site-templates/simple-service/ailk-template.json +0 -11
  159. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  160. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  161. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  162. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  163. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  164. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  165. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  166. package/site-templates/simple-service/provenance.json +0 -4
  167. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  168. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  169. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
@@ -1,42 +1,25 @@
1
1
  /**
2
- * HTML snapshot regression tests for contact/page.tsx → @working-theory/page-renderer migration.
2
+ * contact/page.tsx — render regression + the #6730 D8 contract.
3
3
  *
4
- * Contact uses sections mode (not bodyMode: "mdx-body") with a
5
- * ConversationalIntakeWrapper wrapper:
6
- * <>{renderPage output}<ConversationalIntakeWrapper /></>
4
+ * Contact uses sections mode (not bodyMode: "mdx-body"). Since #6730 the page
5
+ * places ONE contact surface, first match wins: into the page's first
6
+ * `contact-form` section; else into a leading split hero; else after the
7
+ * page. The surface is the shared `LeadCaptureForm` block — or, when the API
8
+ * says this deployment accepts no contact message, `ContactFallback` with the
9
+ * site's contact email and NO form. A failed status read (unreachable API)
10
+ * renders the form: its submit then reports honestly.
7
11
  *
8
- * Two-commit strategy:
9
- * Commit 1: this file added; snapshots captured against pre-migration route (RED-passing).
10
- * Commit 2: route migrated; same snapshots verify diff=0 (GREEN).
11
- *
12
- * #4534 (epic #4521's integration leg) swapped the page's direct
13
- * `<ContactForm/>` mount for `<ConversationalIntakeWrapper/>` (which itself
14
- * renders the existing ContactForm on Skip or a failed auto-submit — see
15
- * that component's own doc-comment). The mock below + the snapshots'
16
- * `contact-form-stub` testid moved with it, deliberately — this is the one
17
- * legitimate case the file's own "DO NOT regenerate to pass" warning
18
- * doesn't cover: the page's actual composition changed, not the renderer.
19
- *
20
- * Two states tested:
21
- * - empty: getMdxSource returns null → renderPage empty-state + wrapper present
22
- * - populated: getMdxSource returns populated source → renderPage sections + wrapper present
23
- *
24
- * Note on ConversationalIntakeWrapper in tests: it composes ContactForm
25
- * (skip/fallback surface), which is a "use client" component using
26
- * useFormState/useFormStatus hooks — jsdom does not support these hooks.
27
- * The wrapper renders as an opaque stub via the next-intl and react-dom
28
- * mocks below. The snapshot asserts the wrapper is present in the output
29
- * (non-null).
30
- *
31
- * Failure = snapshot diff > 0 bytes beyond the deliberate #4534 rename.
12
+ * Boundaries mocked: `@working-theory/ui` (the renderer's section tree is a
13
+ * stub that renders each section's slotted node, so placement is visible),
14
+ * `@working-theory/api-client` (`leads.captureStatus`), `next-intl/server`,
15
+ * the `_site.mdx` contact-email read, and the Server Action module.
32
16
  */
33
17
  import React from "react";
34
- import { render } from "@testing-library/react";
18
+ import { render, screen } from "@testing-library/react";
35
19
 
36
- // ── @working-theory/ui mock ─────────────────────────────────────────────────────────────
20
+ // ── @working-theory/ui mock ─────────────────────────────────────────────────
37
21
  jest.mock("@working-theory/ui", () => ({
38
- // #4446/#4447: renderPage's own fallback/empty-state title now composes
39
- // Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
22
+ // #4446/#4447: renderPage's own fallback/empty-state title composes Heading.
40
23
  Heading: ({
41
24
  children,
42
25
  level,
@@ -44,72 +27,232 @@ jest.mock("@working-theory/ui", () => ({
44
27
  children: React.ReactNode;
45
28
  level: number;
46
29
  }): React.ReactElement => React.createElement(`h${level}`, null, children),
47
- SectionRenderer: ({ sections }: { sections: unknown[] }) =>
48
- React.createElement("div", {
49
- "data-testid": "section-renderer",
50
- "data-section-count": String(sections.length),
51
- }),
30
+ SectionRenderer: ({
31
+ sections,
32
+ sectionSlots,
33
+ }: {
34
+ sections: Array<{ id: string }>;
35
+ sectionSlots?: Record<string, React.ReactNode>;
36
+ }) =>
37
+ React.createElement(
38
+ "div",
39
+ {
40
+ "data-testid": "section-renderer",
41
+ "data-section-count": String(sections.length),
42
+ },
43
+ sections.map((s) =>
44
+ React.createElement(
45
+ "div",
46
+ { key: s.id, "data-section-id": s.id },
47
+ sectionSlots?.[s.id],
48
+ ),
49
+ ),
50
+ ),
51
+ localeHref: (locale: string, path: string) => `/${locale}${path}`,
52
+ LeadCaptureForm: (props: {
53
+ fields?: Array<{ name: string }>;
54
+ submitLabel?: string;
55
+ fallbackEmail?: string;
56
+ termsHref: string;
57
+ }) =>
58
+ React.createElement(
59
+ "div",
60
+ {
61
+ "data-testid": "lead-capture-form",
62
+ "data-fields": (props.fields ?? []).map((f) => f.name).join(","),
63
+ "data-fallback-email": props.fallbackEmail ?? "",
64
+ "data-terms-href": props.termsHref,
65
+ },
66
+ React.createElement("button", { "data-testid": "contact-submit" }, props.submitLabel ?? "Send"),
67
+ ),
68
+ ContactFallback: (props: { email?: string; text?: { noEmail?: string } }) =>
69
+ React.createElement(
70
+ "div",
71
+ { "data-testid": "contact-fallback" },
72
+ props.email
73
+ ? React.createElement("a", { href: `mailto:${props.email}` }, props.email)
74
+ : props.text?.noEmail,
75
+ ),
52
76
  }));
53
77
 
54
- // ── ConversationalIntakeWrapper mock ────────────────────────────────────────
55
- // ConversationalIntakeWrapper composes ContactForm ("use client",
56
- // useFormState/useFormStatus — crashes in jsdom) as its skip/fallback
57
- // surface. Mock the wrapper itself as a deterministic stub so the snapshot
58
- // is stable; the wrapper's own submission/skip/analytics wiring is covered
59
- // by ConversationalIntakeWrapper.test.tsx.
60
- // eslint-disable-next-line no-restricted-syntax -- test-seam mock required; composes ContactForm, which uses useFormState/useFormStatus that crash in jsdom; internal-module mock is the narrowest possible scope; tracked in #507
61
- jest.mock("../ContactForm", () => ({
62
- ContactForm: () =>
63
- React.createElement("div", { "data-testid": "contact-form-stub" }),
78
+ jest.mock("@working-theory/api-client", () => ({
79
+ leads: { captureStatus: jest.fn(), submit: jest.fn() },
64
80
  }));
65
81
 
82
+ // The real English catalog, so renderPage's own strings render as they ship.
83
+ jest.mock("next-intl/server", () => {
84
+ const messages = jest.requireActual("../../../../messages/en.json") as Record<
85
+ string,
86
+ Record<string, unknown>
87
+ >;
88
+ return {
89
+ getTranslations: async (arg: string | { namespace: string }) => {
90
+ const ns = messages[typeof arg === "string" ? arg : arg.namespace] ?? {};
91
+ return (key: string) => (ns[key] ?? key) as string;
92
+ },
93
+ };
94
+ });
95
+
96
+ // 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
97
+ jest.mock("../site-contact-email", () => ({
98
+ readSiteContactEmail: jest.fn(),
99
+ }));
100
+
101
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the page only passes it through
102
+ jest.mock("../contact.actions", () => ({
103
+ captureLeadAction: jest.fn(),
104
+ }));
105
+
106
+ import { leads } from "@working-theory/api-client";
107
+
66
108
  import ContactPage from "../page";
109
+ import { readSiteContactEmail } from "../site-contact-email";
110
+
111
+ const mockCaptureStatus = leads.captureStatus as jest.Mock;
112
+ const mockContactEmail = readSiteContactEmail as jest.Mock;
67
113
 
68
- const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
69
- const getPageSectionsEmpty = jest.fn().mockResolvedValue([]);
114
+ const ACCEPTING = { ok: true, status: 200, body: { ok: true, data: { contact: true, newsletter: false } } };
115
+ const REFUSING = { ok: true, status: 200, body: { ok: true, data: { contact: false, newsletter: false } } };
116
+
117
+ const source = {
118
+ frontmatter: {
119
+ type: "ContactPage" as const,
120
+ slug: "contact",
121
+ title: "Contact Us",
122
+ description: "Get in touch.",
123
+ },
124
+ body: "",
125
+ };
126
+
127
+ const splitHero = { id: "hero", type: "hero", data: { variant: "split", headline: "Hi" } };
128
+ const contactFormSection = {
129
+ id: "contact-form",
130
+ type: "contact-form",
131
+ data: {
132
+ fields: [
133
+ { name: "email", label: "Email", type: "email" },
134
+ { name: "plan", label: "Plan", type: "select", options: ["free", "pro"] },
135
+ ],
136
+ submitLabel: "Request a demo",
137
+ },
138
+ };
139
+
140
+ async function renderWith(sections: unknown[] | null) {
141
+ const element = await ContactPage({
142
+ params: Promise.resolve({ locale: "en" }),
143
+ getMdxSource: jest.fn().mockResolvedValue(sections === null ? null : source),
144
+ getPageSections: jest.fn().mockResolvedValue(sections ?? []),
145
+ });
146
+ return render(element);
147
+ }
148
+
149
+ beforeEach(() => {
150
+ jest.clearAllMocks();
151
+ mockCaptureStatus.mockResolvedValue(ACCEPTING);
152
+ mockContactEmail.mockReturnValue(undefined);
153
+ });
70
154
 
71
155
  describe("ContactPage — snapshot regression", () => {
72
- it("empty state renders deterministic HTML (renderPage empty-state + ContactForm present)", async () => {
73
- const element = await ContactPage({
74
- params: Promise.resolve({ locale: "en" }),
75
- getMdxSource: getMdxSourceEmpty,
76
- getPageSections: getPageSectionsEmpty,
77
- });
78
- const { container } = render(element);
79
- // ContactForm must be present even in empty-state
80
- expect(
81
- container.querySelector('[data-testid="contact-form-stub"]'),
82
- ).not.toBeNull();
156
+ it("empty state renders deterministic HTML (renderPage empty-state + the form after it)", async () => {
157
+ const { container } = await renderWith(null);
83
158
  expect(container.innerHTML).toMatchInlineSnapshot(
84
- `"<main id="main"><h1>Contact</h1><p data-testid="section-empty-state">Contact page content coming soon.</p></main><div data-testid="contact-form-stub"></div>"`,
159
+ `"<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>"`,
85
160
  );
86
161
  });
87
162
 
88
- it("populated state renders deterministic HTML (renderPage sections + ContactForm present)", async () => {
89
- const source = {
90
- frontmatter: {
91
- type: "ContactPage" as const,
92
- slug: "contact",
93
- title: "Contact Us",
94
- description: "Get in touch.",
95
- },
96
- body: "",
97
- };
98
- const getMdxSourcePopulated = jest.fn().mockResolvedValue(source);
99
- const getPageSectionsPopulated = jest.fn().mockResolvedValue([]);
100
-
101
- const element = await ContactPage({
102
- params: Promise.resolve({ locale: "en" }),
103
- getMdxSource: getMdxSourcePopulated,
104
- getPageSections: getPageSectionsPopulated,
105
- });
106
- const { container } = render(element);
107
- // ContactForm must be present in populated state too
163
+ it("populated state renders deterministic HTML (renderPage sections + the form after it)", async () => {
164
+ const { container } = await renderWith([]);
165
+ expect(container.innerHTML).toMatchInlineSnapshot(
166
+ `"<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"><h1>Contact Us</h1><p data-testid="contact-description">Get in touch.</p><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>"`,
167
+ );
168
+ });
169
+ });
170
+
171
+ describe("ContactPage — placement (#6730 D8)", () => {
172
+ it("a contact-form section hosts the form, with that section's fields and label", async () => {
173
+ const { container } = await renderWith([splitHero, contactFormSection]);
174
+
175
+ const slot = container.querySelector("[data-section-id='contact-form']")!;
176
+ const form = slot.querySelector("[data-testid='lead-capture-form']");
177
+ expect(form).not.toBeNull();
178
+ expect(form).toHaveAttribute("data-fields", "email,plan");
179
+ expect(slot).toHaveTextContent("Request a demo");
180
+ expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
181
+ });
182
+
183
+ it("no contact-form section + a leading split hero → inside the hero", async () => {
184
+ const { container } = await renderWith([splitHero]);
185
+
108
186
  expect(
109
- container.querySelector('[data-testid="contact-form-stub"]'),
187
+ container.querySelector("[data-section-id='hero'] [data-testid='lead-capture-form']"),
110
188
  ).not.toBeNull();
111
- expect(container.innerHTML).toMatchInlineSnapshot(
112
- `"<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"><h1>Contact Us</h1><p data-testid="contact-description">Get in touch.</p><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="contact-form-stub"></div>"`,
189
+ expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
190
+ });
191
+
192
+ it("neither → after the page", async () => {
193
+ const { container } = await renderWith([
194
+ { id: "faq", type: "faq", data: { items: [] } },
195
+ ]);
196
+
197
+ expect(container.querySelector("main [data-testid='lead-capture-form']")).toBeNull();
198
+ expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
199
+ });
200
+
201
+ it("passes the site's contact email to the form's unavailable state", async () => {
202
+ mockContactEmail.mockReturnValue("hi@example.com");
203
+ await renderWith([]);
204
+ expect(screen.getByTestId("lead-capture-form")).toHaveAttribute(
205
+ "data-fallback-email",
206
+ "hi@example.com",
113
207
  );
114
208
  });
115
209
  });
210
+
211
+ describe("ContactPage — no lead destination (#6730 D8)", () => {
212
+ it("status contact:false + a contactEmail → the fallback with a mailto link, and no form", async () => {
213
+ mockCaptureStatus.mockResolvedValue(REFUSING);
214
+ mockContactEmail.mockReturnValue("hi@example.com");
215
+
216
+ await renderWith([]);
217
+
218
+ const fallback = screen.getByTestId("contact-fallback");
219
+ expect(fallback.querySelector("a[href='mailto:hi@example.com']")).not.toBeNull();
220
+ expect(screen.queryByTestId("contact-submit")).toBeNull();
221
+ expect(screen.queryByTestId("lead-capture-form")).toBeNull();
222
+ });
223
+
224
+ it("status contact:false + no contactEmail → the no-email copy, and no form", async () => {
225
+ mockCaptureStatus.mockResolvedValue(REFUSING);
226
+
227
+ await renderWith([]);
228
+
229
+ expect(screen.getByTestId("contact-fallback")).toHaveTextContent(
230
+ "Contact details for this site haven't been published yet.",
231
+ );
232
+ expect(screen.queryByTestId("contact-submit")).toBeNull();
233
+ });
234
+
235
+ it("the fallback takes the contact-form section's place too", async () => {
236
+ mockCaptureStatus.mockResolvedValue(REFUSING);
237
+ mockContactEmail.mockReturnValue("hi@example.com");
238
+
239
+ const { container } = await renderWith([contactFormSection]);
240
+
241
+ expect(
242
+ container.querySelector("[data-section-id='contact-form'] [data-testid='contact-fallback']"),
243
+ ).not.toBeNull();
244
+ });
245
+
246
+ it.each([
247
+ ["rejects (unreachable API)", () => mockCaptureStatus.mockRejectedValue(new Error("ECONNREFUSED"))],
248
+ ["404 (older API)", () => mockCaptureStatus.mockResolvedValue({ ok: false, status: 404, body: {} })],
249
+ ["5xx", () => mockCaptureStatus.mockResolvedValue({ ok: false, status: 503, body: {} })],
250
+ ])("status %s → the form renders", async (_label, arrange) => {
251
+ arrange();
252
+
253
+ await renderWith([]);
254
+
255
+ expect(screen.getByTestId("contact-submit")).toBeInTheDocument();
256
+ expect(screen.queryByTestId("contact-fallback")).toBeNull();
257
+ });
258
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * contactFormText — the `contact` message namespace mapped onto the shared
3
+ * `LeadCaptureForm` block's copy (#6730 D8).
4
+ *
5
+ * One pure mapping, shared by the RSC page (`getTranslations`) and the client
6
+ * `ConversationalIntakeWrapper` (`useTranslations`), so the two surfaces that
7
+ * render the form can never label it differently. `unavailableBody` keeps
8
+ * its `{email}` placeholder (interpolated back to itself): the block fills
9
+ * it, since only the block knows the address it is showing.
10
+ */
11
+
12
+ import type { LeadCaptureFormText } from "@working-theory/ui";
13
+
14
+ type ContactTextKey =
15
+ | "nameLabel"
16
+ | "emailLabel"
17
+ | "messageLabel"
18
+ | "sendMessage"
19
+ | "sending"
20
+ | "successMessage"
21
+ | "genericError"
22
+ | "consentStatement"
23
+ | "unavailableHeading"
24
+ | "unavailableBody"
25
+ | "unavailableNoEmail";
26
+
27
+ /** The subset of a `contact`-namespace translator this mapping reads. */
28
+ export type ContactTranslator = (
29
+ key: ContactTextKey,
30
+ values?: { email: string },
31
+ ) => string;
32
+
33
+ /** Keeps the `{email}` placeholder for the block to fill. */
34
+ const KEEP_EMAIL_PLACEHOLDER = { email: "{email}" };
35
+
36
+ export function contactFormText(t: ContactTranslator): LeadCaptureFormText {
37
+ return {
38
+ nameLabel: t("nameLabel"),
39
+ emailLabel: t("emailLabel"),
40
+ messageLabel: t("messageLabel"),
41
+ submit: t("sendMessage"),
42
+ sending: t("sending"),
43
+ success: t("successMessage"),
44
+ genericError: t("genericError"),
45
+ consentStatement: t("consentStatement"),
46
+ unavailableHeading: t("unavailableHeading"),
47
+ unavailableBody: t("unavailableBody", KEEP_EMAIL_PLACEHOLDER),
48
+ unavailableNoEmail: t("unavailableNoEmail"),
49
+ };
50
+ }
@@ -5,12 +5,25 @@
5
5
  *
6
6
  * Validates contact-form input with submitLeadInputSchema (shared schema per
7
7
  * ADR 0013), calls POST /v1/leads/submit via @working-theory/api-client, and returns
8
- * a discriminated ActionResult suitable for useFormState.
8
+ * the shared `LeadCaptureForm` block's `LeadCaptureFormState`.
9
9
  *
10
10
  * Canonical filename: contact.actions.ts (*.actions.ts per ADR 0013 / U0 spec
11
11
  * parity-test regex `ACTION_FILE_RE = /\.actions\.ts$/`).
12
12
  *
13
- * Honeypot + rate-limit enforcement lives in apps/api; no duplication here.
13
+ * Rate-limit enforcement lives in apps/api; the honeypot is checked here.
14
+ *
15
+ * Outcome (#6730 D8): the API's 503 `lead_capture_not_configured` — this
16
+ * deployment has nowhere to put a lead — maps to `{ kind: "unavailable" }`,
17
+ * which the shared `LeadCaptureForm` renders as the site's contact email.
18
+ * Every other non-2xx and every thrown error is the generic `error`; only a
19
+ * 2xx is ever `success`.
20
+ *
21
+ * Extra fields (#6730 D8a): a `contact-form` section can declare fields
22
+ * beyond name/email/message/phone (a `select`, say). Every other string
23
+ * field that is not reserved and not a Next internal (`$ACTION_…`) is carried
24
+ * into `payload` — at most `MAX_EXTRA_FIELDS` keys, each at most
25
+ * `MAX_EXTRA_FIELD_LENGTH` characters, else a form-level error. A reserved
26
+ * name can never override `name`/`email`/`source`.
14
27
  *
15
28
  * `source` (#4534, epic #4521's integration leg): an optional hidden
16
29
  * FormData field so `ConversationalIntakeWrapper` can tag leads it submits
@@ -25,21 +38,62 @@
25
38
  */
26
39
 
27
40
  import { leads } from "@working-theory/api-client";
41
+ import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
28
42
  import { submitLeadInputSchema } from "@working-theory/validation";
29
43
 
30
- export type ActionResult =
31
- | { kind: "success" }
32
- | {
33
- kind: "error";
34
- message?: string;
35
- fieldErrors?: Record<string, string>;
36
- }
37
- | null;
44
+ const GENERIC_ERROR = "Something went wrong, please try again.";
45
+
46
+ /** Field names the action reads itself — never copied into `payload`. */
47
+ const RESERVED_FIELDS = new Set([
48
+ "name",
49
+ "email",
50
+ "message",
51
+ "phone",
52
+ "consent",
53
+ LEAD_CAPTURE_HONEYPOT,
54
+ "source",
55
+ ]);
56
+ const MAX_EXTRA_FIELDS = 20;
57
+ const MAX_EXTRA_FIELD_LENGTH = 2000;
58
+
59
+ /**
60
+ * The declared extra fields, or `null` when they exceed the bounds. Non-string
61
+ * entries (files) and Next's own `$ACTION_…` keys are skipped.
62
+ */
63
+ function extraFields(formData: FormData): Record<string, string> | null {
64
+ const extra: Record<string, string> = {};
65
+ let count = 0;
66
+ for (const [key, value] of formData.entries()) {
67
+ if (RESERVED_FIELDS.has(key) || key.startsWith("$ACTION")) continue;
68
+ if (typeof value !== "string") continue;
69
+ if (value.length > MAX_EXTRA_FIELD_LENGTH) return null;
70
+ if (!Object.hasOwn(extra, key)) count += 1;
71
+ if (count > MAX_EXTRA_FIELDS) return null;
72
+ extra[key] = value;
73
+ }
74
+ return extra;
75
+ }
76
+
77
+ /** A 503 whose body names `lead_capture_not_configured`. */
78
+ function isNotConfigured(res: { status: number; body: unknown }): boolean {
79
+ return (
80
+ res.status === 503 &&
81
+ typeof res.body === "object" &&
82
+ res.body !== null &&
83
+ (res.body as { error?: unknown }).error === "lead_capture_not_configured"
84
+ );
85
+ }
38
86
 
39
87
  export async function captureLeadAction(
40
- _prevState: ActionResult,
88
+ _prevState: LeadCaptureFormState,
41
89
  formData: FormData,
42
- ): Promise<ActionResult> {
90
+ ): Promise<LeadCaptureFormState> {
91
+ // Honeypot: people never see `LEAD_CAPTURE_HONEYPOT`; a bot that fills it
92
+ // gets the same answer a person would, and nothing is sent. The API does
93
+ // not check it.
94
+ const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
95
+ if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
96
+
43
97
  const raw = {
44
98
  name: formData.get("name"),
45
99
  email: formData.get("email"),
@@ -59,6 +113,9 @@ export async function captureLeadAction(
59
113
  return { kind: "error", fieldErrors };
60
114
  }
61
115
 
116
+ const extra = extraFields(formData);
117
+ if (extra === null) return { kind: "error", message: GENERIC_ERROR };
118
+
62
119
  const { name, email, message, phone } = parsed.data;
63
120
  const siteId = process.env.AILK_SITE_ID ?? "default";
64
121
  const source = (formData.get("source") as string | null) || "web";
@@ -67,6 +124,7 @@ export async function captureLeadAction(
67
124
  source,
68
125
  email,
69
126
  payload: {
127
+ ...extra,
70
128
  name,
71
129
  ...(message !== undefined && { message }),
72
130
  ...(phone !== undefined && { phone }),
@@ -76,10 +134,8 @@ export async function captureLeadAction(
76
134
  try {
77
135
  const res = await leads.submit(body);
78
136
  if (!res.ok) {
79
- return {
80
- kind: "error",
81
- message: "Something went wrong, please try again.",
82
- };
137
+ if (isNotConfigured(res)) return { kind: "unavailable" };
138
+ return { kind: "error", message: GENERIC_ERROR };
83
139
  }
84
140
  return { kind: "success" };
85
141
  } catch (err) {
@@ -89,9 +145,6 @@ export async function captureLeadAction(
89
145
  // itself is safe — no submitted form fields (name/email/message/phone)
90
146
  // are included.
91
147
  console.error("[captureLeadAction]", err);
92
- return {
93
- kind: "error",
94
- message: "Something went wrong, please try again.",
95
- };
148
+ return { kind: "error", message: GENERIC_ERROR };
96
149
  }
97
150
  }