create-ailk 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +44 -10
  2. package/component-catalog.json +520 -5
  3. package/component-catalog.md +9 -6
  4. package/dist/cli.js +22 -24
  5. package/dist/lib/extract-module-bundle.js +5 -1
  6. package/dist/lib/module-license-gate.d.ts +79 -0
  7. package/dist/lib/module-license-gate.js +160 -12
  8. package/dist/lib/module-tier.d.ts +45 -16
  9. package/dist/lib/module-tier.js +76 -0
  10. package/dist/lib/pro-callout.js +10 -2
  11. package/dist/parse-args.d.ts +78 -10
  12. package/dist/parse-args.js +204 -14
  13. package/dist/programmatic.d.ts +39 -9
  14. package/dist/programmatic.js +163 -25
  15. package/dist/site-roster.js +11 -6
  16. package/dist/starter-grid.d.ts +216 -0
  17. package/dist/starter-grid.js +449 -0
  18. package/dist/surfaces.d.ts +4 -4
  19. package/dist/surfaces.js +102 -43
  20. package/dist/template-manifest.d.ts +36 -17
  21. package/dist/template-manifest.js +121 -367
  22. package/dist/template-names.d.ts +14 -12
  23. package/dist/template-names.js +13 -17
  24. package/package.json +3 -3
  25. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -1
  26. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  27. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  28. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  29. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  30. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  32. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  33. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  34. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  35. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  36. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  37. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  38. package/site-templates/_base/content/en/UseCase/persona-1.mdx +69 -0
  39. package/site-templates/_base/content/en/UseCase/persona-2.mdx +69 -0
  40. package/site-templates/_base/content/en/UseCase/persona-3.mdx +69 -0
  41. package/site-templates/designs/standard/DESIGN.md +5 -0
  42. package/site-templates/marketing-site/DESIGN.md +5 -0
  43. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +89 -25
  44. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +35 -14
  45. package/site-templates/marketing-site-free/DESIGN.md +5 -0
  46. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +95 -29
  47. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +74 -44
  48. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  49. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  50. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +17 -29
  51. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -1
  52. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  53. package/site-templates/saas-product/DESIGN.md +5 -0
  54. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  55. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +96 -76
  56. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +302 -0
  57. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  58. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  59. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  60. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  61. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  62. package/site-templates/{simple-service → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  63. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  64. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  65. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  66. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  67. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  68. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +362 -0
  69. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +300 -0
  70. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  71. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  72. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  73. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  74. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  75. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  76. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +38 -0
  77. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +38 -0
  78. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +38 -0
  79. package/site-templates/shapes/organization-service/content/en/ServicesList/services.mdx +72 -0
  80. package/site-templates/shapes/organization-service/provenance.json +4 -0
  81. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +369 -0
  82. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +299 -0
  83. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  84. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  85. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  86. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  87. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  88. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +169 -0
  89. package/site-templates/shapes/person-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  90. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  91. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  92. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  93. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  94. package/site-templates/shapes/person-service/provenance.json +4 -0
  95. package/templates/.claude/skills/README.md +3 -2
  96. package/templates/.claude/skills/add-page/references/page-types.md +4 -0
  97. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  98. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  99. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  100. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +19 -28
  101. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  102. package/templates/DESIGN.md +5 -0
  103. package/templates/README.md +46 -38
  104. package/templates/apps/api/CLAUDE.md +3 -2
  105. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  106. package/templates/apps/api/src/config/index.ts +35 -16
  107. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  108. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  109. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  110. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  111. package/templates/apps/api/src/openapi/spec.ts +63 -12
  112. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  113. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  114. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  115. package/templates/apps/api/src/server.ts +7 -0
  116. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  117. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  118. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  119. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  120. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  121. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  122. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  123. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  124. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  125. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  126. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  127. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  128. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  129. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  130. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  131. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  132. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  133. package/templates/apps/web/app/[locale]/layout.tsx +37 -5
  134. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  135. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  136. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  137. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  138. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  139. package/templates/apps/web/app/[locale]/portfolio/page.tsx +49 -0
  140. package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +18 -3
  141. package/templates/apps/web/lib/nav-config.ts +48 -36
  142. package/templates/apps/web/lib/site-identity.ts +14 -0
  143. package/templates/apps/web/messages/en.json +28 -2
  144. package/templates/content/_site.mdx +4 -2
  145. package/templates/content/en/BlogList/blog.mdx +37 -16
  146. package/templates/content/en/ContactPage/contact.mdx +14 -44
  147. package/templates/content/en/HomePage/home.mdx +8 -16
  148. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  149. package/templates/content/en/UseCasesList/use-cases.mdx +6 -6
  150. package/templates/database/CHANGELOG.md +7 -0
  151. package/templates/database/package.json +1 -1
  152. package/versions.json +33 -33
  153. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  154. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  155. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  156. package/site-templates/simple-service/DESIGN.md +0 -162
  157. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  158. package/site-templates/simple-service/ailk-template.json +0 -11
  159. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  160. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  161. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  162. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  163. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  164. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  165. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  166. package/site-templates/simple-service/provenance.json +0 -4
  167. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  168. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  169. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
@@ -1,8 +1,20 @@
1
- // Contact page. Renders sections via renderPage (sections mode) with the
2
- // plain ContactForm slotted into the split hero's right column (or appended
3
- // below when no split hero leads). The chat-style intake
4
- // (ConversationalIntakeWrapper, #4534) remains available to a site that
5
- // prefers it. JSON-LD and translations handled by renderPage.
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 { ContactForm } from "./ContactForm";
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
- // The contact form sits in the hero's right column when the page leads with
41
- // a split hero (the two-column contact layout — operator ruling 2026-09-23:
42
- // the Free template shows the plain form; the guided intake stays a Pro
43
- // option); otherwise — no content, or a different lead section — it renders
44
- // below the page, so the contact surface is never lost.
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
- lead?.type === "hero" && lead.data.variant === "split" ? lead.id : undefined;
50
- const intake = <ContactForm />;
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]: intake } }),
148
+ ...(slotted !== undefined && { sectionSlots: { [slotted]: surface } }),
59
149
  });
60
150
  return (
61
151
  <>
62
152
  {rendered}
63
- {slotted === undefined ? intake : null}
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="Get started with this template."
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: "Download for free",
522
- href: "https://ailaunchkit.ai/docs/get-started",
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
+ });