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,8 +1,20 @@
|
|
|
1
|
-
// Contact page. Renders sections via renderPage (sections mode)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Contact page (#6730 D8). Renders sections via renderPage (sections mode)
|
|
2
|
+
// and places ONE contact surface, first match wins: into the page's first
|
|
3
|
+
// `contact-form` section; else into a leading split hero's right column;
|
|
4
|
+
// else after the page.
|
|
5
|
+
//
|
|
6
|
+
// That surface is the shared `LeadCaptureForm` block, bound to
|
|
7
|
+
// `captureLeadAction` — unless the API says this deployment accepts no
|
|
8
|
+
// contact message (production with neither a database nor an email key), in
|
|
9
|
+
// which case it is `ContactFallback`: the site's contact email, never a form
|
|
10
|
+
// that would report a message as sent when it reached nobody. An unreachable
|
|
11
|
+
// API, an older API without the status route, or any other failed read is
|
|
12
|
+
// treated as accepting: the form renders, and its submit reports honestly.
|
|
13
|
+
//
|
|
14
|
+
// The chat-style intake (ConversationalIntakeWrapper, #4534) remains
|
|
15
|
+
// available to a site that prefers it. JSON-LD and translations handled by
|
|
16
|
+
// renderPage.
|
|
17
|
+
import { leads } from "@working-theory/api-client";
|
|
6
18
|
import type {
|
|
7
19
|
getMdxSource as defaultGetMdxSource,
|
|
8
20
|
getPageSections as defaultGetPageSections,
|
|
@@ -13,10 +25,21 @@ import {
|
|
|
13
25
|
renderPage,
|
|
14
26
|
renderPageMetadata,
|
|
15
27
|
} from "@working-theory/page-renderer";
|
|
28
|
+
import {
|
|
29
|
+
ContactFallback,
|
|
30
|
+
type ContactFormField,
|
|
31
|
+
LeadCaptureForm,
|
|
32
|
+
localeHref,
|
|
33
|
+
} from "@working-theory/ui";
|
|
16
34
|
import type { Metadata } from "next";
|
|
35
|
+
import { getTranslations } from "next-intl/server";
|
|
17
36
|
import type { ReactElement } from "react";
|
|
18
37
|
|
|
19
|
-
import {
|
|
38
|
+
import { contactFormText } from "./contact-form-text";
|
|
39
|
+
import { captureLeadAction } from "./contact.actions";
|
|
40
|
+
import { readSiteContactEmail } from "./site-contact-email";
|
|
41
|
+
|
|
42
|
+
export const revalidate = 60;
|
|
20
43
|
|
|
21
44
|
interface Props {
|
|
22
45
|
params: Promise<{ locale: string }>;
|
|
@@ -24,6 +47,41 @@ interface Props {
|
|
|
24
47
|
getPageSections?: typeof defaultGetPageSections;
|
|
25
48
|
}
|
|
26
49
|
|
|
50
|
+
type CaptureStatus = Extract<
|
|
51
|
+
Awaited<ReturnType<typeof leads.captureStatus>>,
|
|
52
|
+
{ ok: true }
|
|
53
|
+
>["body"]["data"];
|
|
54
|
+
|
|
55
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
56
|
+
type ContactFormSection = Extract<Section, { type: "contact-form" }>;
|
|
57
|
+
|
|
58
|
+
const FIELD_TYPES = ["text", "email", "tel", "textarea", "select"] as const;
|
|
59
|
+
|
|
60
|
+
/** A `contact-form` section's declared fields, as the form block renders them. */
|
|
61
|
+
function sectionFields(section: ContactFormSection): ContactFormField[] {
|
|
62
|
+
return section.data.fields.map((f) => ({
|
|
63
|
+
name: f.name,
|
|
64
|
+
label: f.label,
|
|
65
|
+
type: (FIELD_TYPES as readonly string[]).includes(f.type)
|
|
66
|
+
? (f.type as (typeof FIELD_TYPES)[number])
|
|
67
|
+
: "text",
|
|
68
|
+
...(f.required !== undefined ? { required: f.required } : {}),
|
|
69
|
+
...(f.placeholder !== undefined ? { placeholder: f.placeholder } : {}),
|
|
70
|
+
...(f.options !== undefined ? { options: f.options } : {}),
|
|
71
|
+
...(f.colSpan !== undefined ? { colSpan: f.colSpan as 1 | 2 } : {}),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The API's capture status, or `null` for any failed read (see header). */
|
|
76
|
+
async function readCaptureStatus(): Promise<CaptureStatus | null> {
|
|
77
|
+
try {
|
|
78
|
+
const res = await leads.captureStatus();
|
|
79
|
+
return res.ok && res.body.ok === true ? res.body.data : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
27
85
|
export async function generateMetadata({
|
|
28
86
|
params,
|
|
29
87
|
}: Pick<Props, "params">): Promise<Metadata> {
|
|
@@ -37,17 +95,49 @@ export default async function Page({
|
|
|
37
95
|
getPageSections,
|
|
38
96
|
}: Props): Promise<ReactElement> {
|
|
39
97
|
const { locale } = await params;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
98
|
+
const [status, t] = await Promise.all([
|
|
99
|
+
readCaptureStatus(),
|
|
100
|
+
getTranslations({ locale, namespace: "contact" }),
|
|
101
|
+
]);
|
|
102
|
+
const accepting = status?.contact ?? true;
|
|
103
|
+
const contactEmail = readSiteContactEmail();
|
|
104
|
+
const text = contactFormText(t);
|
|
105
|
+
|
|
45
106
|
const source = await (getMdxSource ?? loadMdxSource)("ContactPage", "contact", locale);
|
|
46
107
|
const sections = source ? await (getPageSections ?? loadPageSections)(source.frontmatter) : [];
|
|
108
|
+
const formSection = sections.find(
|
|
109
|
+
(s): s is ContactFormSection => s.type === "contact-form",
|
|
110
|
+
);
|
|
111
|
+
// The section's own published address stands in when _site.mdx has none.
|
|
112
|
+
const fallbackEmail = contactEmail ?? formSection?.data.contactInfo?.email;
|
|
47
113
|
const lead = sections[0];
|
|
48
114
|
const slotted =
|
|
49
|
-
|
|
50
|
-
|
|
115
|
+
formSection?.id ??
|
|
116
|
+
(lead?.type === "hero" && lead.data.variant === "split" ? lead.id : undefined);
|
|
117
|
+
|
|
118
|
+
const surface = accepting ? (
|
|
119
|
+
<LeadCaptureForm
|
|
120
|
+
action={captureLeadAction}
|
|
121
|
+
text={text}
|
|
122
|
+
termsHref={localeHref(locale, "/terms")}
|
|
123
|
+
fallbackEmail={fallbackEmail}
|
|
124
|
+
{...(formSection && {
|
|
125
|
+
fields: sectionFields(formSection),
|
|
126
|
+
submitLabel: formSection.data.submitLabel,
|
|
127
|
+
successMessage: formSection.data.successMessage,
|
|
128
|
+
})}
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
131
|
+
<ContactFallback
|
|
132
|
+
email={fallbackEmail}
|
|
133
|
+
text={{
|
|
134
|
+
heading: text.unavailableHeading,
|
|
135
|
+
body: text.unavailableBody,
|
|
136
|
+
noEmail: text.unavailableNoEmail,
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
|
|
51
141
|
const rendered = await renderPage({
|
|
52
142
|
type: "ContactPage",
|
|
53
143
|
slug: "contact",
|
|
@@ -55,12 +145,12 @@ export default async function Page({
|
|
|
55
145
|
translationsNamespace: "contact",
|
|
56
146
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
57
147
|
...(getPageSections !== undefined && { getPageSections }),
|
|
58
|
-
...(slotted !== undefined && { sectionSlots: { [slotted]:
|
|
148
|
+
...(slotted !== undefined && { sectionSlots: { [slotted]: surface } }),
|
|
59
149
|
});
|
|
60
150
|
return (
|
|
61
151
|
<>
|
|
62
152
|
{rendered}
|
|
63
|
-
{slotted === undefined ?
|
|
153
|
+
{slotted === undefined ? surface : null}
|
|
64
154
|
</>
|
|
65
155
|
);
|
|
66
156
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's published contact email (#6730 D10) — `_site.mdx`'s top-level
|
|
3
|
+
* `contactEmail`, else a `LocalBusiness` identity's `email`, else `undefined`.
|
|
4
|
+
*
|
|
5
|
+
* `/contact` shows it in place of the form when the site has nowhere to
|
|
6
|
+
* deliver a message, and hands it to the form's `unavailable` state.
|
|
7
|
+
*
|
|
8
|
+
* A local frontmatter read rather than `@working-theory/site-config/server`'s
|
|
9
|
+
* `loadSiteConfig`: `architecture.yaml` forbids `apps/web` from importing that
|
|
10
|
+
* subpath (it pulls `node:fs` into a tree that also carries edge routes,
|
|
11
|
+
* #1567) — the same reason `lib/site-theme.ts` and `lib/site-brand.tsx` read
|
|
12
|
+
* locally. The value is still validated against the canonical schema from
|
|
13
|
+
* the client-safe `/schema` entry. Any failure (no file, no frontmatter, an
|
|
14
|
+
* invalid address) is `undefined`: the fallback then says the site has
|
|
15
|
+
* published no contact details, which is true.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { SiteConfigSchema } from "@working-theory/site-config/schema";
|
|
22
|
+
import { parse as parseYaml } from "yaml";
|
|
23
|
+
|
|
24
|
+
import { resolveContentRoot } from "../../../lib/site-brand";
|
|
25
|
+
|
|
26
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
27
|
+
|
|
28
|
+
const EmailSchema = SiteConfigSchema.shape.contactEmail.unwrap();
|
|
29
|
+
|
|
30
|
+
function validEmail(value: unknown): string | undefined {
|
|
31
|
+
const parsed = EmailSchema.safeParse(value);
|
|
32
|
+
return parsed.success ? parsed.data : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readSiteContactEmail(): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
|
|
38
|
+
const match = FRONTMATTER_RE.exec(raw);
|
|
39
|
+
if (!match) return undefined;
|
|
40
|
+
const fm: unknown = parseYaml(match[1] ?? "");
|
|
41
|
+
if (!fm || typeof fm !== "object") return undefined;
|
|
42
|
+
|
|
43
|
+
const { contactEmail, siteIdentity } = fm as {
|
|
44
|
+
contactEmail?: unknown;
|
|
45
|
+
siteIdentity?: { kind?: unknown; email?: unknown };
|
|
46
|
+
};
|
|
47
|
+
return (
|
|
48
|
+
validEmail(contactEmail) ??
|
|
49
|
+
(siteIdentity?.kind === "LocalBusiness" ? validEmail(siteIdentity.email) : undefined)
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Feature detail page (#6721) — one feature at /features/<slug>, listed by
|
|
2
|
+
// the /features page. Reads the Feature MDX for the given slug, renders via
|
|
3
|
+
// @working-theory/page-renderer, and emits a JSON-LD WebPage whose mainEntity
|
|
4
|
+
// is the product's SoftwareApplication, named for the feature. Unknown slugs
|
|
5
|
+
// 404. Composed `sections[]` render as sections; a prose page renders its
|
|
6
|
+
// MDX body.
|
|
7
|
+
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
8
|
+
import {
|
|
9
|
+
getMdxSource as loadMdxSource,
|
|
10
|
+
getMdxSlugs,
|
|
11
|
+
renderPage,
|
|
12
|
+
renderPageMetadata,
|
|
13
|
+
} from "@working-theory/page-renderer";
|
|
14
|
+
import type { Metadata } from "next";
|
|
15
|
+
import type { ReactElement } from "react";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
19
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function generateMetadata({
|
|
23
|
+
params,
|
|
24
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
25
|
+
const { locale, slug } = await params;
|
|
26
|
+
return renderPageMetadata({ type: "Feature", slug, locale });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function generateStaticParams({
|
|
30
|
+
params,
|
|
31
|
+
}: {
|
|
32
|
+
params: { locale: string };
|
|
33
|
+
}): Promise<{ slug: string }[]> {
|
|
34
|
+
const slugs = await getMdxSlugs("Feature", params.locale);
|
|
35
|
+
return slugs.map((slug) => ({ slug }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default async function Page({
|
|
39
|
+
params,
|
|
40
|
+
getMdxSource,
|
|
41
|
+
}: Props): Promise<ReactElement> {
|
|
42
|
+
const { locale, slug } = await params;
|
|
43
|
+
// A page that composes `sections[]` (the starter seeds, #6721) renders
|
|
44
|
+
// them; a prose page renders its MDX body — the same rule the /features hub
|
|
45
|
+
// applies. JSON-LD is built from the frontmatter either way.
|
|
46
|
+
const source = await (getMdxSource ?? loadMdxSource)("Feature", slug, locale);
|
|
47
|
+
const composed =
|
|
48
|
+
source !== null &&
|
|
49
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
50
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
51
|
+
0;
|
|
52
|
+
return renderPage({
|
|
53
|
+
type: "Feature",
|
|
54
|
+
slug,
|
|
55
|
+
locale,
|
|
56
|
+
...(composed
|
|
57
|
+
? {}
|
|
58
|
+
: {
|
|
59
|
+
bodyMode: "mdx-body" as const,
|
|
60
|
+
populatedDescriptionTestId: "feature-description",
|
|
61
|
+
}),
|
|
62
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Features list page. Reads FeaturesList MDX via the content adapter and emits
|
|
2
|
+
// a JSON-LD CollectionPage via the dispatcher. List-only — no detail route.
|
|
3
|
+
import type {
|
|
4
|
+
getMdxSource as defaultGetMdxSource,
|
|
5
|
+
} from "@working-theory/page-renderer";
|
|
6
|
+
import {
|
|
7
|
+
getMdxSource as loadMdxSource,
|
|
8
|
+
renderPage,
|
|
9
|
+
renderPageMetadata,
|
|
10
|
+
} from "@working-theory/page-renderer";
|
|
11
|
+
import type { Metadata } from "next";
|
|
12
|
+
import type { ReactElement } from "react";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
params: Promise<{ locale: string }>;
|
|
16
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function generateMetadata({
|
|
20
|
+
params,
|
|
21
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
22
|
+
const { locale } = await params;
|
|
23
|
+
return renderPageMetadata({
|
|
24
|
+
type: "FeaturesList",
|
|
25
|
+
slug: "features",
|
|
26
|
+
locale,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default async function Page({
|
|
31
|
+
params,
|
|
32
|
+
getMdxSource,
|
|
33
|
+
}: Props): Promise<ReactElement> {
|
|
34
|
+
const { locale } = await params;
|
|
35
|
+
// A FeaturesList page that composes `sections[]` (the Free template's
|
|
36
|
+
// /features, operator ruling 2026-09-23) renders them; a prose-only page
|
|
37
|
+
// keeps the MDX-body render. The CollectionPage JSON-LD is built from the
|
|
38
|
+
// sections either way (buildFeaturesList).
|
|
39
|
+
const source = await (getMdxSource ?? loadMdxSource)("FeaturesList", "features", locale);
|
|
40
|
+
const composed =
|
|
41
|
+
source !== null &&
|
|
42
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
43
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
|
|
44
|
+
return renderPage({
|
|
45
|
+
type: "FeaturesList",
|
|
46
|
+
slug: "features",
|
|
47
|
+
locale,
|
|
48
|
+
translationsNamespace: "features",
|
|
49
|
+
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
50
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -27,7 +27,7 @@ import { Telemetry, VerificationMeta } from "@working-theory/analytics";
|
|
|
27
27
|
import { MarketingShell } from "@working-theory/templates/shells";
|
|
28
28
|
import { themeToCssVarsBothModes } from "@working-theory/theme";
|
|
29
29
|
import { Link, isRtl, localeHref } from "@working-theory/ui";
|
|
30
|
-
import { LocaleSwitcherNav } from "@working-theory/ui/blocks";
|
|
30
|
+
import { BRAND_PATHS, LocaleSwitcherNav } from "@working-theory/ui/blocks";
|
|
31
31
|
import {
|
|
32
32
|
Banner,
|
|
33
33
|
Footer,
|
|
@@ -238,6 +238,14 @@ export default async function LocaleLayout({
|
|
|
238
238
|
feature4: tNav("feature4"),
|
|
239
239
|
feature5: tNav("feature5"),
|
|
240
240
|
allFeatures: tNav("allFeatures"),
|
|
241
|
+
// #6721 — the two-column Product flyout + the Use Cases footer column.
|
|
242
|
+
flyoutFeaturesHeading: tNav("flyoutFeaturesHeading"),
|
|
243
|
+
flyoutUseCasesHeading: tNav("flyoutUseCasesHeading"),
|
|
244
|
+
footerUseCasesHeading: tNav("footerUseCasesHeading"),
|
|
245
|
+
useCase1: tNav("useCase1"),
|
|
246
|
+
useCase2: tNav("useCase2"),
|
|
247
|
+
useCase3: tNav("useCase3"),
|
|
248
|
+
allUseCases: tNav("allUseCases"),
|
|
241
249
|
howItWorks: tNav("howItWorks"),
|
|
242
250
|
howItWorksDesc: tNav("howItWorksDesc"),
|
|
243
251
|
features: tNav("features"),
|
|
@@ -378,6 +386,9 @@ export default async function LocaleLayout({
|
|
|
378
386
|
href: "https://linkedin.com/company/{{COMPANY_HANDLE}}",
|
|
379
387
|
},
|
|
380
388
|
{ platform: "youtube", href: "https://youtube.com/@{{COMPANY_HANDLE}}" },
|
|
389
|
+
// #6739 — the kit's own repo, operator-directed: the Free template links
|
|
390
|
+
// back to where it came from.
|
|
391
|
+
{ platform: "github", href: "https://github.com/working-theory-labs/ai-launch-kit" },
|
|
381
392
|
] as const;
|
|
382
393
|
// Footer column groups (#6468), generated beside `navItems` as label keys;
|
|
383
394
|
// `Footer` takes translated strings, so resolve them through the same map.
|
|
@@ -400,6 +411,10 @@ export default async function LocaleLayout({
|
|
|
400
411
|
const legalLinks = [
|
|
401
412
|
{ label: "Privacy Policy", href: "/privacy" },
|
|
402
413
|
{ label: "Terms of Service", href: "/terms" },
|
|
414
|
+
// #6739 — a link, not the reopen button: the privacy page's
|
|
415
|
+
// #cookie-settings anchor explains the choice, and arriving there
|
|
416
|
+
// reopens the consent banner (BannerConsentIsland).
|
|
417
|
+
{ label: tConsent("settingsLabel"), href: "/privacy#cookie-settings" },
|
|
403
418
|
...(siteChrome.footer === "centered"
|
|
404
419
|
? [{ label: navLabels.items.faq, href: "/faq" }]
|
|
405
420
|
: []),
|
|
@@ -515,11 +530,29 @@ export default async function LocaleLayout({
|
|
|
515
530
|
variant="contrast"
|
|
516
531
|
placement="above-nav"
|
|
517
532
|
// NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
|
|
518
|
-
message=
|
|
533
|
+
message={
|
|
534
|
+
<>
|
|
535
|
+
<a
|
|
536
|
+
href="https://github.com/working-theory-labs/ai-launch-kit"
|
|
537
|
+
className="inline-flex items-center gap-1 font-bold text-current underline underline-offset-2"
|
|
538
|
+
>
|
|
539
|
+
Get started for free
|
|
540
|
+
<svg
|
|
541
|
+
viewBox="0 0 24 24"
|
|
542
|
+
aria-hidden="true"
|
|
543
|
+
className="size-4 fill-current"
|
|
544
|
+
>
|
|
545
|
+
<path d={BRAND_PATHS.github} />
|
|
546
|
+
</svg>
|
|
547
|
+
</a>
|
|
548
|
+
{"\u00A0 "}
|
|
549
|
+
or
|
|
550
|
+
</>
|
|
551
|
+
}
|
|
519
552
|
messageWeight="regular"
|
|
520
553
|
action={{
|
|
521
|
-
label: "
|
|
522
|
-
href: "https://ailaunchkit.ai/
|
|
554
|
+
label: "upgrade to Pro",
|
|
555
|
+
href: "https://ailaunchkit.ai/pricing",
|
|
523
556
|
kind: "link",
|
|
524
557
|
}}
|
|
525
558
|
routes={{ include: [homePath] }}
|
|
@@ -592,7 +625,6 @@ export default async function LocaleLayout({
|
|
|
592
625
|
bottomBarAlign="content"
|
|
593
626
|
copyrightText={copyrightText}
|
|
594
627
|
legalLinks={legalLinks}
|
|
595
|
-
consentSettingsLabel={tConsent("settingsLabel")}
|
|
596
628
|
languageSwitcher={
|
|
597
629
|
<LocaleSwitcherNav
|
|
598
630
|
current={locale}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subscribeNewsletterAction (#6730 D9) — the Server Action twin of the
|
|
3
|
+
* `ailkSubscribeNewsletter` MCP tool.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { subscribeNewsletterAction } from "../newsletter.actions.js";
|
|
7
|
+
|
|
8
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
9
|
+
leads: { subscribe: jest.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { leads } from "@working-theory/api-client";
|
|
13
|
+
const mockSubscribe = leads.subscribe as jest.Mock;
|
|
14
|
+
|
|
15
|
+
function formWith(email: string): FormData {
|
|
16
|
+
const fd = new FormData();
|
|
17
|
+
fd.append("email", email);
|
|
18
|
+
fd.append("consent", "true");
|
|
19
|
+
return fd;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => jest.clearAllMocks());
|
|
23
|
+
|
|
24
|
+
describe("subscribeNewsletterAction", () => {
|
|
25
|
+
it("calls leads.subscribe with source newsletter and the address", async () => {
|
|
26
|
+
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
27
|
+
|
|
28
|
+
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual({ kind: "success" });
|
|
31
|
+
expect(mockSubscribe).toHaveBeenCalledWith(
|
|
32
|
+
expect.objectContaining({ source: "newsletter", email: "ada@example.com" }),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("an invalid address → a field error, no API call", async () => {
|
|
37
|
+
const result = await subscribeNewsletterAction(null, formWith("not-an-email"));
|
|
38
|
+
|
|
39
|
+
expect(result).toMatchObject({ kind: "error", fieldErrors: { email: expect.any(String) } });
|
|
40
|
+
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("503 lead_capture_not_configured → unavailable", async () => {
|
|
44
|
+
mockSubscribe.mockResolvedValueOnce({
|
|
45
|
+
ok: false,
|
|
46
|
+
status: 503,
|
|
47
|
+
body: { error: "lead_capture_not_configured" },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
51
|
+
|
|
52
|
+
expect(result).toEqual({ kind: "unavailable" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("any other failure → the generic error, never success", async () => {
|
|
56
|
+
mockSubscribe.mockResolvedValueOnce({ ok: false, status: 503, body: { error: "persist_failed" } });
|
|
57
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
|
|
58
|
+
kind: "error",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
mockSubscribe.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
62
|
+
jest.spyOn(console, "error").mockImplementationOnce(() => undefined);
|
|
63
|
+
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
|
|
64
|
+
kind: "error",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("a filled honeypot → success, and nothing is sent", async () => {
|
|
69
|
+
const fd = formWith("ada@example.com");
|
|
70
|
+
fd.append("ailk_hp", "spam");
|
|
71
|
+
|
|
72
|
+
expect(await subscribeNewsletterAction(null, fd)).toEqual({ kind: "success" });
|
|
73
|
+
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* newsletter/page.tsx — a signup only when there is somewhere to keep it
|
|
3
|
+
* (#6730 D9).
|
|
4
|
+
*
|
|
5
|
+
* Boundaries mocked: `@working-theory/ui` (the section tree is a stub that
|
|
6
|
+
* renders one element per section, with a `newsletter` section rendering the
|
|
7
|
+
* form its content declares), `@working-theory/api-client`
|
|
8
|
+
* (`leads.captureStatus`), `next-intl/server`, and the Server Action module.
|
|
9
|
+
*/
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { render, screen } from "@testing-library/react";
|
|
12
|
+
|
|
13
|
+
jest.mock("@working-theory/ui", () => ({
|
|
14
|
+
Heading: ({ children, level }: { children: React.ReactNode; level: number }) =>
|
|
15
|
+
React.createElement(`h${level}`, null, children),
|
|
16
|
+
SectionRenderer: ({
|
|
17
|
+
sections,
|
|
18
|
+
}: {
|
|
19
|
+
sections: Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
type: string;
|
|
22
|
+
data: { action?: string; primaryCta?: { href: string } };
|
|
23
|
+
}>;
|
|
24
|
+
}) =>
|
|
25
|
+
React.createElement(
|
|
26
|
+
"div",
|
|
27
|
+
{ "data-testid": "section-renderer" },
|
|
28
|
+
sections.map((s) =>
|
|
29
|
+
s.type === "newsletter"
|
|
30
|
+
? React.createElement(
|
|
31
|
+
"form",
|
|
32
|
+
{ key: s.id, "data-section-id": s.id, action: s.data.action },
|
|
33
|
+
"signup",
|
|
34
|
+
)
|
|
35
|
+
: React.createElement(
|
|
36
|
+
"div",
|
|
37
|
+
{ key: s.id, "data-section-id": s.id },
|
|
38
|
+
s.data.primaryCta
|
|
39
|
+
? React.createElement("a", { href: s.data.primaryCta.href }, "cta")
|
|
40
|
+
: null,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
localeHref: (locale: string, path: string) => `/${locale}${path}`,
|
|
45
|
+
LeadCaptureForm: (props: { fields?: Array<{ name: string; type: string }> }) =>
|
|
46
|
+
React.createElement("div", {
|
|
47
|
+
"data-testid": "lead-capture-form",
|
|
48
|
+
"data-fields": (props.fields ?? []).map((f) => `${f.name}:${f.type}`).join(","),
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
53
|
+
leads: { captureStatus: jest.fn() },
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
jest.mock("next-intl/server", () => ({
|
|
57
|
+
getTranslations: async () => (key: string) => key,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// 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
|
|
61
|
+
jest.mock("../newsletter.actions", () => ({
|
|
62
|
+
subscribeNewsletterAction: jest.fn(),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
import { leads } from "@working-theory/api-client";
|
|
66
|
+
|
|
67
|
+
import NewsletterPage from "../page";
|
|
68
|
+
|
|
69
|
+
const mockCaptureStatus = leads.captureStatus as jest.Mock;
|
|
70
|
+
const status = (newsletter: boolean) => ({
|
|
71
|
+
ok: true,
|
|
72
|
+
status: 200,
|
|
73
|
+
body: { ok: true, data: { contact: true, newsletter } },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const source = {
|
|
77
|
+
frontmatter: {
|
|
78
|
+
type: "NewsletterSignup" as const,
|
|
79
|
+
slug: "newsletter",
|
|
80
|
+
title: "Newsletter",
|
|
81
|
+
description: "What subscribers get.",
|
|
82
|
+
},
|
|
83
|
+
body: "",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** `_base`'s content: a hero whose CTA points at `#signup`, the signup section, an FAQ. */
|
|
87
|
+
const BASE_SECTIONS = [
|
|
88
|
+
{ id: "hero", type: "hero", data: { headline: "News", primaryCta: { label: "Subscribe", href: "#signup" } } },
|
|
89
|
+
{ id: "signup", type: "newsletter", data: { action: "/v1/leads/subscribe", source: "newsletter" } },
|
|
90
|
+
{ id: "faq", type: "faq", data: { items: [] } },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** #6738's Free content: no signup section. */
|
|
94
|
+
const FREE_SECTIONS = [
|
|
95
|
+
{ id: "hero", type: "hero", data: { headline: "News" } },
|
|
96
|
+
{ id: "faq", type: "faq", data: { items: [] } },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
async function renderWith(sections: unknown[]) {
|
|
100
|
+
const element = await NewsletterPage({
|
|
101
|
+
params: Promise.resolve({ locale: "en" }),
|
|
102
|
+
getMdxSource: jest.fn().mockResolvedValue(source),
|
|
103
|
+
getPageSections: jest.fn().mockResolvedValue(sections),
|
|
104
|
+
});
|
|
105
|
+
return render(element);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeEach(() => jest.clearAllMocks());
|
|
109
|
+
|
|
110
|
+
describe("NewsletterPage — #6730 D9", () => {
|
|
111
|
+
it("newsletter:true + no newsletter section → one appended form with a single email field", async () => {
|
|
112
|
+
mockCaptureStatus.mockResolvedValue(status(true));
|
|
113
|
+
|
|
114
|
+
await renderWith(FREE_SECTIONS);
|
|
115
|
+
|
|
116
|
+
expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
|
|
117
|
+
expect(screen.getByTestId("lead-capture-form")).toHaveAttribute(
|
|
118
|
+
"data-fields",
|
|
119
|
+
"email:email",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("newsletter:false + no newsletter section → no form at all", async () => {
|
|
124
|
+
mockCaptureStatus.mockResolvedValue(status(false));
|
|
125
|
+
|
|
126
|
+
const { container } = await renderWith(FREE_SECTIONS);
|
|
127
|
+
|
|
128
|
+
expect(screen.queryByTestId("lead-capture-form")).toBeNull();
|
|
129
|
+
expect(container.querySelector("form")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("newsletter:true + _base's newsletter section → that section renders, no second form", async () => {
|
|
133
|
+
mockCaptureStatus.mockResolvedValue(status(true));
|
|
134
|
+
|
|
135
|
+
const { container } = await renderWith(BASE_SECTIONS);
|
|
136
|
+
|
|
137
|
+
expect(container.querySelector("form[action='/v1/leads/subscribe']")).not.toBeNull();
|
|
138
|
+
expect(screen.queryByTestId("lead-capture-form")).toBeNull();
|
|
139
|
+
expect(container.querySelector("a[href='#signup']")).not.toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it.each([
|
|
143
|
+
["newsletter:false", () => mockCaptureStatus.mockResolvedValue(status(false))],
|
|
144
|
+
["the status read rejecting", () => mockCaptureStatus.mockRejectedValue(new Error("ECONNREFUSED"))],
|
|
145
|
+
["a 404 (older API)", () => mockCaptureStatus.mockResolvedValue({ ok: false, status: 404, body: {} })],
|
|
146
|
+
])(
|
|
147
|
+
"%s + _base's newsletter section → the section is absent, and so is the hero CTA to it",
|
|
148
|
+
async (_label, arrange) => {
|
|
149
|
+
arrange();
|
|
150
|
+
|
|
151
|
+
const { container } = await renderWith(BASE_SECTIONS);
|
|
152
|
+
|
|
153
|
+
expect(container.querySelector("[data-section-id='signup']")).toBeNull();
|
|
154
|
+
expect(container.querySelector("form")).toBeNull();
|
|
155
|
+
expect(container.innerHTML).not.toContain("/v1/leads/subscribe");
|
|
156
|
+
expect(container.querySelector("a[href='#signup']")).toBeNull();
|
|
157
|
+
expect(screen.queryByTestId("lead-capture-form")).toBeNull();
|
|
158
|
+
// The rest of the page still renders.
|
|
159
|
+
expect(container.querySelector("[data-section-id='hero']")).not.toBeNull();
|
|
160
|
+
expect(container.querySelector("[data-section-id='faq']")).not.toBeNull();
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
});
|