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.
- package/README.md +44 -10
- package/component-catalog.json +520 -5
- package/component-catalog.md +9 -6
- package/dist/cli.js +22 -24
- package/dist/lib/extract-module-bundle.js +5 -1
- package/dist/lib/module-license-gate.d.ts +79 -0
- package/dist/lib/module-license-gate.js +160 -12
- package/dist/lib/module-tier.d.ts +45 -16
- package/dist/lib/module-tier.js +76 -0
- package/dist/lib/pro-callout.js +10 -2
- package/dist/parse-args.d.ts +78 -10
- package/dist/parse-args.js +204 -14
- package/dist/programmatic.d.ts +39 -9
- package/dist/programmatic.js +163 -25
- package/dist/site-roster.js +11 -6
- package/dist/starter-grid.d.ts +216 -0
- package/dist/starter-grid.js +449 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.js +102 -43
- package/dist/template-manifest.d.ts +36 -17
- package/dist/template-manifest.js +121 -367
- package/dist/template-names.d.ts +14 -12
- package/dist/template-names.js +13 -17
- package/package.json +3 -3
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -1
- package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
- package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
- package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
- package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
- package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
- package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +69 -0
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +69 -0
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +69 -0
- package/site-templates/designs/standard/DESIGN.md +5 -0
- package/site-templates/marketing-site/DESIGN.md +5 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +89 -25
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +35 -14
- package/site-templates/marketing-site-free/DESIGN.md +5 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +95 -29
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +74 -44
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +17 -29
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -1
- package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
- package/site-templates/saas-product/DESIGN.md +5 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
- package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +96 -76
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +302 -0
- package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
- package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
- package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{simple-service → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
- package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
- package/site-templates/shapes/local-business-service/provenance.json +4 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +362 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +300 -0
- package/site-templates/shapes/organization-service/ailk-template.json +10 -0
- package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
- package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/shapes/organization-service/provenance.json +4 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +369 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +299 -0
- package/site-templates/shapes/person-service/ailk-template.json +10 -0
- package/site-templates/shapes/person-service/content/_site.mdx +85 -0
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +169 -0
- package/site-templates/shapes/person-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
- package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
- package/site-templates/shapes/person-service/provenance.json +4 -0
- package/templates/.claude/skills/README.md +3 -2
- package/templates/.claude/skills/add-page/references/page-types.md +4 -0
- package/templates/.claude/skills/setup-database/SKILL.md +156 -0
- package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
- package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +19 -28
- package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
- package/templates/DESIGN.md +5 -0
- package/templates/README.md +46 -38
- package/templates/apps/api/CLAUDE.md +3 -2
- package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
- package/templates/apps/api/src/config/index.ts +35 -16
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
- package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
- package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
- package/templates/apps/api/src/openapi/spec.ts +63 -12
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
- package/templates/apps/api/src/routes/leads/index.ts +46 -1
- package/templates/apps/api/src/server.ts +7 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
- package/templates/apps/api/src/services/lead-routing.ts +140 -30
- package/templates/apps/api/src/services/leads-inbox.ts +48 -11
- package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
- package/templates/apps/mcp/src/tools/create_project.ts +21 -6
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
- package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
- package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
- package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/layout.tsx +37 -5
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +18 -3
- package/templates/apps/web/lib/nav-config.ts +48 -36
- package/templates/apps/web/lib/site-identity.ts +14 -0
- package/templates/apps/web/messages/en.json +28 -2
- package/templates/content/_site.mdx +4 -2
- package/templates/content/en/BlogList/blog.mdx +37 -16
- package/templates/content/en/ContactPage/contact.mdx +14 -44
- package/templates/content/en/HomePage/home.mdx +8 -16
- package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
- package/templates/content/en/UseCasesList/use-cases.mdx +6 -6
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
- package/site-templates/simple-service/DESIGN.md +0 -162
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
- package/site-templates/simple-service/ailk-template.json +0 -11
- package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
- package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
- package/site-templates/simple-service/provenance.json +0 -4
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
- package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
- package/templates/content/en/SoftwareProduct/product.mdx +0 -175
|
@@ -1,42 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* contact/page.tsx — render regression + the #6730 D8 contract.
|
|
3
3
|
*
|
|
4
|
-
* Contact uses sections mode (not bodyMode: "mdx-body")
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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: ({
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
69
|
-
const
|
|
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 +
|
|
73
|
-
const
|
|
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="
|
|
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 +
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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=
|
|
187
|
+
container.querySelector("[data-section-id='hero'] [data-testid='lead-capture-form']"),
|
|
110
188
|
).not.toBeNull();
|
|
111
|
-
expect(
|
|
112
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
88
|
+
_prevState: LeadCaptureFormState,
|
|
41
89
|
formData: FormData,
|
|
42
|
-
): Promise<
|
|
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
|
-
|
|
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
|
}
|