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
@@ -103,7 +103,6 @@ const MCP_ONLY_TOOLS = new Set([
103
103
  "ailkGetPricing", // get_pricing — pure read, no action equivalent (I4)
104
104
  "ailkGetFaq", // get_faq — pure read, no action equivalent (I4)
105
105
  "ailkAudit", // audit — AEO scoring tool, no web action equivalent (I5; ADR 0013)
106
- "ailkSubscribeNewsletter", // subscribe_newsletter — anonymous write via MCP; no Server Action (B7)
107
106
  "ailkRequestCallback", // request_callback — anonymous write via MCP; no Server Action (B7)
108
107
  "ailkCreateProject", // create_project — agent-only project creator; no Server Action equivalent (#299)
109
108
  "ailkGetFlow", // get_flow — agent-callable flow-engine read; no web Server Action (#3956 D7)
@@ -228,6 +227,8 @@ describe("MCP ↔ Server-Action parity (filesystem)", () => {
228
227
  // ailkSubmitFlowStep): twenty tools + three paired Server Actions.
229
228
  // - ailkPurchase: paired with purchaseAction (apps/web/app/dev/purchase/).
230
229
  // - ailkCaptureLead: paired with captureLeadAction (apps/web/app/contact/).
230
+ // - ailkSubscribeNewsletter: paired with subscribeNewsletterAction
231
+ // (apps/web/app/[locale]/newsletter/newsletter.actions.ts, #6730 D9).
231
232
  // - ailkSubmitFlowStep: paired with submitFlowStepAction
232
233
  // (apps/web/app/[locale]/flows/[slug]/flow.actions.ts, #3957).
233
234
  // - startCheckoutAction, saveRecommendationAction: WEB-ONLY (#4842 D7,
@@ -241,8 +242,8 @@ describe("MCP ↔ Server-Action parity (filesystem)", () => {
241
242
  // ailkGetLocations, ailkGetOfferings, ailkGetPricing, ailkGetFaq:
242
243
  // MCP-only (pure read; I4; in MCP_ONLY_TOOLS allow-list).
243
244
  // - ailkAudit: MCP-only (AEO scoring; I5; in MCP_ONLY_TOOLS allow-list).
244
- // - ailkSubscribeNewsletter, ailkRequestCallback:
245
- // MCP-only anonymous-write tools (B7; in MCP_ONLY_TOOLS allow-list).
245
+ // - ailkRequestCallback: MCP-only anonymous-write tool (B7; in
246
+ // MCP_ONLY_TOOLS allow-list).
246
247
  // - ailkCreateProject: MCP-only agent project creator (#299; in MCP_ONLY_TOOLS).
247
248
  // - ailkCreatePage, ailkUpdatePage, ailkDeletePage: RETIRED by #5853
248
249
  // (D7). Their HTTP twins — the untenanted /v1/content writes — were
@@ -264,7 +265,7 @@ describe("MCP ↔ Server-Action parity (filesystem)", () => {
264
265
  // - ailkGetEventMeta: MCP-only meta-only scheduling read, twin of
265
266
  // GET /v1/schedule/event-meta (#4633) — same posture as the other
266
267
  // scheduling tools.
267
- expect(actions.size).toBe(5);
268
+ expect(actions.size).toBe(6);
268
269
  expect(tools.size).toBe(20);
269
270
  expect(r.missingTools).toEqual([]);
270
271
  expect(r.orphanTools).toEqual([]);
@@ -16,6 +16,12 @@
16
16
  * correctly in production and in integration tests.
17
17
  *
18
18
  * Input: { targetDir, projectName, preset? }
19
+ * The tool builds the Free organization-product starter (`--shape
20
+ * organization-product`, #6754 D9); `preset` stays reserved. Always builds
21
+ * the Free module set (`--modules marketing`, #6759 D6, SEC-7): a licence
22
+ * key sitting in the MCP server's own environment never widens what an
23
+ * anonymous caller receives — the explicit keep list takes precedence over
24
+ * any key-derived level probe before the child process reads its env.
19
25
  * targetDir — absolute or relative path to scaffold into; must not exist
20
26
  * or must be empty (enforced by create-ailk ValidationError)
21
27
  * projectName — project name used in README and package.json
@@ -94,12 +100,21 @@ export async function ailkCreateProject(
94
100
  const targetAbs = resolve(input.targetDir);
95
101
 
96
102
  // Spawn the CLI synchronously — no network, no async I/O needed.
97
- // Hard-coded node invocation; only targetDir flows through user input.
98
- const result = spawnSync("node", [bin, targetAbs], {
99
- stdio: ["ignore", "pipe", "pipe"],
100
- shell: false,
101
- encoding: "utf8",
102
- });
103
+ // Hard-coded node invocation; only targetDir flows through user input. The
104
+ // CLI refuses a run naming no shape and no template, so the tool names one.
105
+ // `--modules marketing` is pinned explicitly (#6759 D6, SEC-7): an explicit
106
+ // keep list takes the keep branch and resolves before the child ever reads
107
+ // AILK_LICENSE_KEY, so a licence key in the server's own environment can
108
+ // never turn this anonymous call into the keyed level probe.
109
+ const result = spawnSync(
110
+ "node",
111
+ [bin, targetAbs, "--shape", "organization-product", "--modules", "marketing"],
112
+ {
113
+ stdio: ["ignore", "pipe", "pipe"],
114
+ shell: false,
115
+ encoding: "utf8",
116
+ },
117
+ );
103
118
 
104
119
  if (result.status !== 0) {
105
120
  // Extract the error message from stderr (CLI writes "[create-ailk] <msg>").
@@ -8,8 +8,8 @@
8
8
  * conversation logic, only:
9
9
  *
10
10
  * 1. Submission — `onComplete` hands the finished answers to the SAME
11
- * `captureLeadAction` (`./contact.actions.ts`) the plain `<ContactForm/>`
12
- * already uses, built as a `FormData` (imperatively, not a real
11
+ * `captureLeadAction` (`./contact.actions.ts`) the shared
12
+ * `LeadCaptureForm` on `/contact` already uses, built as a `FormData` (imperatively, not a real
13
13
  * `<form>` submit — React 19 Server Actions are plain async functions).
14
14
  * That action posts through the EXISTING `POST /v1/leads/submit` route
15
15
  * (`apps/api/src/routes/leads`, consumed via `@working-theory/api-client`,
@@ -21,7 +21,8 @@
21
21
  * second web surface, not a new one. `contact.actions.ts`'s own
22
22
  * doc-comment covers the additive `source` FormData field this file
23
23
  * sets to distinguish intake-originated leads.
24
- * 2. Skip fallback — `onSkip` swaps to the EXISTING `<ContactForm/>`,
24
+ * 2. Skip fallback — `onSkip` swaps to the shared `LeadCaptureForm` block
25
+ * (#6730 — the one contact form), bound to the same action,
25
26
  * carrying over whatever answers were already captured as its
26
27
  * `initialValues` prefill. A failed auto-submit on `onComplete` falls
27
28
  * back the same way, so a lead is never silently lost.
@@ -43,14 +44,16 @@ import {
43
44
  ConversationalIntake,
44
45
  type ConversationalIntakeProps,
45
46
  InlineMessage,
47
+ LeadCaptureForm,
48
+ localeHref,
46
49
  THINKING_DELAY_MAX_MS,
47
50
  THINKING_DELAY_MIN_MS,
48
51
  } from "@working-theory/ui";
49
52
  import { useLocale, useTranslations } from "next-intl";
50
53
  import { useCallback, useMemo, useRef, useState } from "react";
51
54
 
55
+ import { contactFormText } from "./contact-form-text";
52
56
  import { captureLeadAction } from "./contact.actions";
53
- import { ContactForm, type ContactFormInitialValues } from "./ContactForm";
54
57
  import { recordIntakeTurn } from "./intake-analytics";
55
58
  import { buildContactIntakeScript } from "./intake-script";
56
59
 
@@ -120,7 +123,7 @@ export function ConversationalIntakeWrapper({
120
123
  );
121
124
 
122
125
  const [surface, setSurface] = useState<Surface>("intake");
123
- const [initialValues, setInitialValues] = useState<ContactFormInitialValues>(
126
+ const [initialValues, setInitialValues] = useState<Record<string, string>>(
124
127
  {},
125
128
  );
126
129
  const [submitError, setSubmitError] = useState<string | undefined>(
@@ -193,7 +196,12 @@ export function ConversationalIntakeWrapper({
193
196
  {submitError}
194
197
  </InlineMessage>
195
198
  ) : null}
196
- <ContactForm initialValues={initialValues} />
199
+ <LeadCaptureForm
200
+ action={captureLeadAction}
201
+ text={contactFormText(t)}
202
+ termsHref={localeHref(locale, "/terms")}
203
+ initialValues={initialValues}
204
+ />
197
205
  </Container>
198
206
  );
199
207
  }
@@ -6,8 +6,8 @@
6
6
  * the EXISTING lead-capture endpoint. Only the outermost network
7
7
  * boundary is mocked (`@working-theory/api-client`'s `leads.submit`) —
8
8
  * the REAL `ConversationalIntake` block (#4543) and its engine (#4532),
9
- * the REAL `captureLeadAction` (the SAME action `<ContactForm/>` already
10
- * uses), and the REAL `submitLeadInputSchema` validation all run, proving
9
+ * the REAL `captureLeadAction` (the SAME action the shared
10
+ * `LeadCaptureForm` on `/contact` uses), and the REAL `submitLeadInputSchema` validation all run, proving
11
11
  * the whole chain end to end. No file under apps/api is imported, mocked,
12
12
  * or touched by this test or by the wiring it exercises.
13
13
  * AC2 — Skip renders the existing form, carrying over answers already
@@ -36,13 +36,14 @@ jest.mock("@working-theory/analytics/server", () => ({
36
36
  }));
37
37
 
38
38
  // ConversationalIntakeWrapper's own responsibility under test here is the
39
- // submission/skip/analytics wiring around the block — not ContactForm's own
40
- // rendering, which apps/web's existing tests already document as crashing
41
- // in jsdom (useFormState/useFormStatus; see page-renderer-migration.test.tsx).
42
- // Narrow internal-module mock, same precedent as that file's ContactForm stub.
43
- // eslint-disable-next-line no-restricted-syntax -- test-seam mock required; ContactForm uses useFormState/useFormStatus which crash in jsdom (see page-renderer-migration.test.tsx); narrowest possible scope
44
- jest.mock("../ContactForm", () => ({
45
- ContactForm: (props: { initialValues?: Record<string, string> }) =>
39
+ // submission/skip/analytics wiring around the block — not the shared
40
+ // LeadCaptureForm's own rendering, which its block suite covers
41
+ // (packages/ui/src/blocks/__tests__/LeadCaptureForm.test.tsx). The real
42
+ // `@working-theory/ui` runs (the ConversationalIntake block included); only
43
+ // the form block is stubbed, so the carried-over `initialValues` can be read.
44
+ jest.mock("@working-theory/ui", () => ({
45
+ ...jest.requireActual("@working-theory/ui"),
46
+ LeadCaptureForm: (props: { initialValues?: Record<string, string> }) =>
46
47
  require("react").createElement("div", {
47
48
  "data-testid": "contact-form-stub",
48
49
  "data-initial-values": JSON.stringify(props.initialValues ?? {}),
@@ -0,0 +1,32 @@
1
+ /**
2
+ * contactFormText (#6730 D8) — the `contact` namespace mapped onto the shared
3
+ * form block's copy, with `unavailableBody`'s `{email}` placeholder kept for
4
+ * the block to fill.
5
+ */
6
+ import messages from "../../../../messages/en.json";
7
+ import { contactFormText } from "../contact-form-text";
8
+
9
+ /** A translator over the English catalog that interpolates `{name}` values, as ICU does. */
10
+ function translator(key: string, values?: Record<string, string>): string {
11
+ const template = (messages.contact as Record<string, unknown>)[key];
12
+ if (typeof template !== "string") throw new Error(`missing contact.${key}`);
13
+ return template.replace(/\{(\w+)\}/g, (m, name: string) => values?.[name] ?? m);
14
+ }
15
+
16
+ describe("contactFormText", () => {
17
+ it("maps every block string from the English catalog, keeping {email} for the block", () => {
18
+ expect(contactFormText(translator)).toEqual({
19
+ nameLabel: "Name",
20
+ emailLabel: "Email",
21
+ messageLabel: "Message",
22
+ submit: "Send message",
23
+ sending: "Sending…",
24
+ success: messages.contact.successMessage,
25
+ genericError: messages.contact.genericError,
26
+ consentStatement: messages.contact.consentStatement,
27
+ unavailableHeading: messages.contact.unavailableHeading,
28
+ unavailableBody: "Email us at {email} and we'll reply.",
29
+ unavailableNoEmail: messages.contact.unavailableNoEmail,
30
+ });
31
+ });
32
+ });
@@ -179,3 +179,132 @@ describe("captureLeadAction — API error", () => {
179
179
  consoleErrorSpy.mockRestore();
180
180
  });
181
181
  });
182
+
183
+ // ─── #6730 D8 — honest outcomes and declared extra fields ────────────────────
184
+
185
+ describe("captureLeadAction — outcome mapping (#6730)", () => {
186
+ it("503 lead_capture_not_configured → unavailable", async () => {
187
+ mockSubmit.mockResolvedValueOnce({
188
+ ok: false,
189
+ status: 503,
190
+ body: { error: "lead_capture_not_configured", message: "Lead capture is not configured." },
191
+ });
192
+
193
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
194
+
195
+ expect(result).toEqual({ kind: "unavailable" });
196
+ });
197
+
198
+ it("503 delivery_failed → the generic error, never success", async () => {
199
+ mockSubmit.mockResolvedValueOnce({
200
+ ok: false,
201
+ status: 503,
202
+ body: { error: "delivery_failed", message: "The message could not be delivered." },
203
+ });
204
+
205
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
206
+
207
+ expect(result).toMatchObject({ kind: "error" });
208
+ expect(result).not.toMatchObject({ kind: "success" });
209
+ });
210
+
211
+ it.each([400, 429, 500, 503])("a non-2xx (%s) is never success", async (status) => {
212
+ mockSubmit.mockResolvedValueOnce({ ok: false, status, body: { error: "x" } });
213
+
214
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
215
+
216
+ expect(result?.kind).toBe("error");
217
+ });
218
+
219
+ it("201 → success", async () => {
220
+ mockSubmit.mockResolvedValueOnce({
221
+ ok: true,
222
+ body: { id: "email-1-abcdef12", persisted: "email" },
223
+ });
224
+
225
+ const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
226
+
227
+ expect(result).toEqual({ kind: "success" });
228
+ });
229
+ });
230
+
231
+ describe("captureLeadAction — declared extra fields (#6730 D8a)", () => {
232
+ it("carries an extra string field (e.g. a select) into payload", async () => {
233
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
234
+
235
+ await captureLeadAction(INITIAL_STATE, makeFormData({ ...VALID_FIELDS, plan: "pro" }));
236
+
237
+ const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
238
+ expect(body.payload.plan).toBe("pro");
239
+ });
240
+
241
+ it("never copies reserved or Next-internal fields into payload", async () => {
242
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
243
+
244
+ await captureLeadAction(
245
+ INITIAL_STATE,
246
+ makeFormData({
247
+ ...VALID_FIELDS,
248
+ consent: "true",
249
+ ailk_hp: "",
250
+ source: "conversational-intake",
251
+ $ACTION_ID_abc: "",
252
+ }),
253
+ );
254
+
255
+ const body = mockSubmit.mock.calls[0][0] as {
256
+ source: string;
257
+ payload: Record<string, unknown>;
258
+ };
259
+ expect(Object.keys(body.payload).sort()).toEqual(["message", "name"]);
260
+ expect(body.source).toBe("conversational-intake");
261
+ });
262
+
263
+ it("more than 20 extra keys → a form error, no API call", async () => {
264
+ const extras = Object.fromEntries(
265
+ Array.from({ length: 21 }, (_, i) => [`extra${i}`, "x"]),
266
+ );
267
+
268
+ const result = await captureLeadAction(
269
+ INITIAL_STATE,
270
+ makeFormData({ ...VALID_FIELDS, ...extras }),
271
+ );
272
+
273
+ expect(result).toMatchObject({ kind: "error" });
274
+ expect(mockSubmit).not.toHaveBeenCalled();
275
+ });
276
+
277
+ it("an extra value over 2000 characters → a form error, no API call", async () => {
278
+ const result = await captureLeadAction(
279
+ INITIAL_STATE,
280
+ makeFormData({ ...VALID_FIELDS, notes: "x".repeat(2001) }),
281
+ );
282
+
283
+ expect(result).toMatchObject({ kind: "error" });
284
+ expect(mockSubmit).not.toHaveBeenCalled();
285
+ });
286
+ });
287
+
288
+ describe("captureLeadAction — honeypot (#6730)", () => {
289
+ it("a filled honeypot → success, and nothing is sent", async () => {
290
+ const result = await captureLeadAction(
291
+ INITIAL_STATE,
292
+ makeFormData({ ...VALID_FIELDS, ailk_hp: "https://spam.example" }),
293
+ );
294
+
295
+ expect(result).toEqual({ kind: "success" });
296
+ expect(mockSubmit).not.toHaveBeenCalled();
297
+ });
298
+
299
+ it("a declared `website` field is an ordinary field, carried into payload", async () => {
300
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
301
+
302
+ await captureLeadAction(
303
+ INITIAL_STATE,
304
+ makeFormData({ ...VALID_FIELDS, website: "https://acme.example" }),
305
+ );
306
+
307
+ const body = mockSubmit.mock.calls[0][0] as { payload: Record<string, unknown> };
308
+ expect(body.payload.website).toBe("https://acme.example");
309
+ });
310
+ });