create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,369 @@
1
+ // oss: false
2
+ /**
3
+ * magnet-page.tsx — the standard magnet page pair (#8840 D8).
4
+ *
5
+ * Every multi-step magnet is two pages, the canon's #5053 split moved to the
6
+ * magnet's own path: an indexable LANDING (`/<magnet>`, inside the site's
7
+ * chrome — a centred hero of the flow record's `landing` copy, a CTA to
8
+ * `/<magnet>/start`, and the `Assessment` page type's single-layer
9
+ * `WebApplication` JSON-LD) and a `noindex, follow` WALK (`/<magnet>/start`,
10
+ * or the `startPath` a landing names, as `/trial` names `/trial/request` —
11
+ * #9287 DD10; chrome-free by `lib/magnet-routes.ts`, the back arrow alone
12
+ * above step 1).
13
+ * This module is the only code that renders either half: `/flows/<slug>`,
14
+ * the careers apply pages and every pair `create-ailk add-route magnet`
15
+ * writes are callers of the factories below.
16
+ *
17
+ * The read: one `flows.get(slug)` shared per server instance for 15 seconds
18
+ * per slug (the /contact idiom, #8731), so a page's metadata, its body and a
19
+ * burst of views make one hub read. A slug the schema refuses is `absent`
20
+ * with no read and no cache entry, each insert first drops the stale
21
+ * entries, and the map holds at most 256 slugs, so random URLs cannot grow
22
+ * it (SEC-8840-G). A refused
23
+ * connection or a 5xx is `unavailable`: both pages then show the site's
24
+ * contact email instead of a 500, are `noindex`, and retry nothing. A site
25
+ * with no hub to ask — the read answers 503 `flows_not_configured`, or a
26
+ * failed read with no API origin configured — is `not-configured` (#9219);
27
+ * a pair renders it as `unavailable`, and `/reports/<slug>` uses it to 404
28
+ * an unknown slug where a hub outage shows the contact email; a landing that
29
+ * passes `notFoundWhenNotConfigured` (`/trial`, #9423) 404s on it too. A walk
30
+ * that exists apart from its flow (`/careers/apply`) passes a `title`: its
31
+ * fallback then carries that page heading, and an absent flow renders the
32
+ * same fallback rather than a 404 (#9219).
33
+ *
34
+ * A flow whose served config carries a `deliverable` (#8848 D7) has one
35
+ * route, `/reports/<slug>`: both halves redirect there (307) before
36
+ * rendering anything, so `/flows/<slug>` and every `add-route magnet` pair
37
+ * hand it over and an ungated deliverable never gets a form here. The
38
+ * deliverable's own walk calls `renderMagnetStart` with `asDeliverable`.
39
+ */
40
+ import { flows } from "@working-theory/api-client";
41
+ import { JsonLd } from "@working-theory/jsonld";
42
+ import { flowRouteMetadata } from "@working-theory/metadata";
43
+ import { buildJsonLd } from "@working-theory/schema";
44
+ import { ContactFallback, Container, Heading, localeHref } from "@working-theory/ui";
45
+ import { NavBackLink } from "@working-theory/ui/chrome";
46
+ import { SectionRenderer } from "@working-theory/ui/sections";
47
+ import type { FlowConfig, PageSection } from "@working-theory/validation";
48
+ import { flowSlugSchema, resolveLocalizedString } from "@working-theory/validation";
49
+ import type { Metadata } from "next";
50
+ import { notFound, redirect } from "next/navigation";
51
+ import { getTranslations } from "next-intl/server";
52
+ import type { ReactElement } from "react";
53
+
54
+ import { readSiteContactEmail } from "../../contact/site-contact-email";
55
+
56
+ import { FlowStepperClient } from "./FlowStepperClient";
57
+
58
+ /** The marker every standard magnet page carries in its header comment (D13). */
59
+ export const MAGNET_PAGE_MARKER = "Standard magnet page (#8840)";
60
+
61
+ /** How long one slug's read is shared across requests (/contact's #8731 value). */
62
+ const MAGNET_READ_TTL_MS = 15_000;
63
+
64
+ /** At most this many slugs are held at once; past it a read is not shared. */
65
+ const MAGNET_READ_MAX_ENTRIES = 256;
66
+
67
+ export type MagnetRead =
68
+ | { kind: "ok"; config: FlowConfig }
69
+ | { kind: "absent" }
70
+ | { kind: "unavailable" }
71
+ | { kind: "not-configured" };
72
+
73
+ const magnetReads = new Map<string, { at: number; read: Promise<MagnetRead> }>();
74
+
75
+ /**
76
+ * Whether an API origin is configured: the three names `getApiBase()` in
77
+ * packages/api-client/src/http.ts reads, in step with it (as `/pricing`
78
+ * checks them). Without one that function answers `localhost:3001`.
79
+ */
80
+ function apiOriginConfigured(): boolean {
81
+ return Boolean(
82
+ process.env.AILK_API_URL ||
83
+ process.env.NEXT_PUBLIC_AILK_API_URL ||
84
+ process.env.NEXT_PUBLIC_API_URL,
85
+ );
86
+ }
87
+
88
+ /** The flows route's own answer when its hub has no database (apps/api `flows_not_configured`). */
89
+ function isFlowsNotConfigured(body: unknown): boolean {
90
+ const error = (body as { error?: { code?: unknown } } | null)?.error;
91
+ return error?.code === "flows_not_configured";
92
+ }
93
+
94
+ /** A read that failed: `not-configured` when there is no hub to ask, else `unavailable`. */
95
+ function failedRead(): MagnetRead {
96
+ return apiOriginConfigured() ? { kind: "unavailable" } : { kind: "not-configured" };
97
+ }
98
+
99
+ async function fetchMagnet(slug: string): Promise<MagnetRead> {
100
+ try {
101
+ const res = await flows.get(slug);
102
+ if (res.ok) return { kind: "ok", config: res.body };
103
+ if (res.status === 503 && isFlowsNotConfigured(res.body)) return { kind: "not-configured" };
104
+ return res.status >= 500 ? failedRead() : { kind: "absent" };
105
+ } catch {
106
+ return failedRead();
107
+ }
108
+ }
109
+
110
+ /** The served flow config for `slug`, shared per instance for 15 seconds. */
111
+ export function readMagnet(slug: string): Promise<MagnetRead> {
112
+ if (!flowSlugSchema.safeParse(slug).success) {
113
+ return Promise.resolve({ kind: "absent" });
114
+ }
115
+ const now = Date.now();
116
+ const hit = magnetReads.get(slug);
117
+ if (hit && now - hit.at < MAGNET_READ_TTL_MS) return hit.read;
118
+ for (const [key, entry] of magnetReads) {
119
+ if (now - entry.at >= MAGNET_READ_TTL_MS) magnetReads.delete(key);
120
+ }
121
+ const read = fetchMagnet(slug);
122
+ if (magnetReads.size < MAGNET_READ_MAX_ENTRIES) magnetReads.set(slug, { at: now, read });
123
+ return read;
124
+ }
125
+
126
+ /** The landing's title and line: the record's `landing` copy, else today's. */
127
+ async function landingCopy(
128
+ config: FlowConfig,
129
+ locale: string,
130
+ ): Promise<{ title: string; description: string; meta?: string; cta: string }> {
131
+ const t = await getTranslations({ locale, namespace: "flow" });
132
+ const pick = (value: Record<string, string> | undefined): string | undefined =>
133
+ value === undefined ? undefined : resolveLocalizedString(value, locale, config.defaultLocale);
134
+ const { landing } = config;
135
+ const meta = pick(landing?.meta);
136
+ return {
137
+ title: pick(landing?.title) ?? pick(config.steps[0]!.copy.title)!,
138
+ description: pick(landing?.description) ?? t("landingIntro"),
139
+ ...(meta !== undefined ? { meta } : {}),
140
+ cta: pick(landing?.cta) ?? t("getStarted"),
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The site's contact email in place of a magnet the hub cannot serve (D8).
146
+ * With a `title`, the page's own `<h1>` sits above it (#9219).
147
+ */
148
+ export async function magnetUnavailable(locale: string, title?: string): Promise<ReactElement> {
149
+ const t = await getTranslations({ locale, namespace: "flow" });
150
+ return (
151
+ <Container className="py-section">
152
+ <div className="mx-auto max-w-container-sm">
153
+ {title !== undefined ? (
154
+ <Heading level={1} className="mb-block-tight">
155
+ {title}
156
+ </Heading>
157
+ ) : null}
158
+ <ContactFallback
159
+ email={readSiteContactEmail()}
160
+ text={{
161
+ heading: t("unavailableHeading"),
162
+ body: t("unavailableBody"),
163
+ noEmail: t("unavailableNoEmail"),
164
+ }}
165
+ />
166
+ </div>
167
+ </Container>
168
+ );
169
+ }
170
+
171
+ export interface MagnetLandingInput {
172
+ slug: string;
173
+ /** The landing's own path, e.g. `/diagnostic`; the CTA goes to `<path>/start`. */
174
+ path: string;
175
+ locale: string;
176
+ /** #9287 DD10 — the walk's path when it is not `<path>/start` (`/trial/request`). */
177
+ startPath?: string;
178
+ /**
179
+ * #9423 — a landing that exists only to sell its hub's flow (`/trial`) is a
180
+ * 404 on a site with no hub to ask (`not-configured`), as `/reports/<slug>`
181
+ * is (#9219). A configured hub that cannot answer still shows the contact
182
+ * email.
183
+ */
184
+ notFoundWhenNotConfigured?: boolean;
185
+ }
186
+
187
+ /** The indexable landing of a magnet pair (D8). */
188
+ export async function renderMagnetLanding({
189
+ slug,
190
+ path,
191
+ locale,
192
+ startPath = `${path}/start`,
193
+ notFoundWhenNotConfigured = false,
194
+ }: MagnetLandingInput): Promise<ReactElement> {
195
+ const read = await readMagnet(slug);
196
+ if (read.kind === "absent") notFound();
197
+ if (read.kind === "not-configured" && notFoundWhenNotConfigured) notFound();
198
+ if (read.kind !== "ok") return magnetUnavailable(locale);
199
+ // #8848 D7 — a deliverable's one route.
200
+ if (read.config.deliverable !== undefined) redirect(localeHref(locale, `/reports/${slug}`));
201
+
202
+ const copy = await landingCopy(read.config, locale);
203
+ const sections: PageSection[] = [
204
+ {
205
+ id: "flow-landing-hero",
206
+ type: "hero",
207
+ data: {
208
+ variant: "centered",
209
+ headline: copy.title,
210
+ subheadline: copy.description,
211
+ ...(copy.meta !== undefined ? { body: copy.meta } : {}),
212
+ primaryCta: { label: copy.cta, href: localeHref(locale, startPath) },
213
+ },
214
+ },
215
+ ];
216
+
217
+ return (
218
+ <>
219
+ <JsonLd
220
+ schema={buildJsonLd({
221
+ type: "Assessment",
222
+ slug: read.config.slug,
223
+ title: copy.title,
224
+ description: copy.description,
225
+ locale,
226
+ })}
227
+ />
228
+ <main>
229
+ <SectionRenderer sections={sections} locale={locale} />
230
+ </main>
231
+ </>
232
+ );
233
+ }
234
+
235
+ /** The landing's metadata: indexable under the site's own rule; a fallback never is. */
236
+ export async function renderMagnetLandingMetadata({
237
+ slug,
238
+ locale,
239
+ notFoundWhenNotConfigured = false,
240
+ }: Pick<MagnetLandingInput, "slug" | "locale" | "notFoundWhenNotConfigured">): Promise<Metadata> {
241
+ const read = await readMagnet(slug);
242
+ if (read.kind === "absent") return {};
243
+ if (read.kind === "not-configured" && notFoundWhenNotConfigured) return {};
244
+ if (read.kind !== "ok") return flowRouteMetadata();
245
+ const copy = await landingCopy(read.config, locale);
246
+ return { title: copy.title, description: copy.description };
247
+ }
248
+
249
+ export interface MagnetStartInput {
250
+ slug: string;
251
+ locale: string;
252
+ /** Where the back arrow goes, unlocalized (the landing, or the posting). */
253
+ backHref: string;
254
+ /** Page-supplied `hidden` field values (a posting's slug as `job`). */
255
+ hiddenValues?: Readonly<Record<string, string>>;
256
+ /** Replaces step 1's title at every locale it holds (a posting's title). */
257
+ firstStepTitle?: string;
258
+ /** #8848 D7 — the deliverable's own walk (`/reports/<slug>/start`): no redirect. */
259
+ asDeliverable?: boolean;
260
+ /**
261
+ * #9219 — a walk that exists apart from its flow (`/careers/apply`): the
262
+ * `<h1>` of its fallback, which an absent flow renders too, never a 404.
263
+ */
264
+ title?: string;
265
+ }
266
+
267
+ function withFirstStepTitle(config: FlowConfig, title: string): FlowConfig {
268
+ const [first, ...rest] = config.steps;
269
+ if (!first) return config;
270
+ const titled = Object.fromEntries(Object.keys(first.copy.title).map((key) => [key, title]));
271
+ return { ...config, steps: [{ ...first, copy: { ...first.copy, title: titled } }, ...rest] };
272
+ }
273
+
274
+ /** The chrome-free, noindex walk of a magnet pair (D8). */
275
+ export async function renderMagnetStart({
276
+ slug,
277
+ locale,
278
+ backHref,
279
+ hiddenValues,
280
+ firstStepTitle,
281
+ asDeliverable = false,
282
+ title,
283
+ }: MagnetStartInput): Promise<ReactElement> {
284
+ const read = await readMagnet(slug);
285
+ if (read.kind === "absent" && title === undefined) notFound();
286
+ if (read.kind === "ok" && read.config.deliverable !== undefined && !asDeliverable) {
287
+ redirect(localeHref(locale, `/reports/${slug}/start`));
288
+ }
289
+ const t = await getTranslations({ locale, namespace: "flow" });
290
+ const back = <NavBackLink href={localeHref(locale, backHref)} label={t("back")} />;
291
+ if (read.kind !== "ok") {
292
+ return (
293
+ <>
294
+ <div className="px-control-x pt-control-y">{back}</div>
295
+ {await magnetUnavailable(locale, title)}
296
+ </>
297
+ );
298
+ }
299
+
300
+ const config =
301
+ firstStepTitle !== undefined ? withFirstStepTitle(read.config, firstStepTitle) : read.config;
302
+ return (
303
+ <FlowStepperClient
304
+ config={config}
305
+ locale={locale}
306
+ slug={read.config.slug}
307
+ header={back}
308
+ {...(hiddenValues !== undefined ? { hiddenValues } : {})}
309
+ />
310
+ );
311
+ }
312
+
313
+ /** The walk's metadata: always `noindex, follow` (`flowRouteMetadata`). */
314
+ export async function renderMagnetStartMetadata({
315
+ slug,
316
+ locale,
317
+ firstStepTitle,
318
+ title,
319
+ }: Pick<MagnetStartInput, "slug" | "locale" | "firstStepTitle" | "title">): Promise<Metadata> {
320
+ const read = await readMagnet(slug);
321
+ if (read.kind !== "ok") return flowRouteMetadata(title !== undefined ? { title } : {});
322
+ const firstStep = read.config.steps[0];
323
+ if (!firstStep) return flowRouteMetadata();
324
+ return flowRouteMetadata({
325
+ title:
326
+ firstStepTitle ??
327
+ resolveLocalizedString(firstStep.copy.title, locale, read.config.defaultLocale),
328
+ });
329
+ }
330
+
331
+ // ─── Factories for a pair at a path (D8, D12) ──────────────────────────────
332
+
333
+ /** A magnet pair: the flow it walks, and the landing's own path. */
334
+ export interface MagnetRef {
335
+ slug: string;
336
+ path: string;
337
+ }
338
+
339
+ interface MagnetPageProps {
340
+ params: Promise<{ locale: string }>;
341
+ }
342
+
343
+ export function magnetLanding(ref: MagnetRef) {
344
+ return async function MagnetLandingPage({ params }: MagnetPageProps): Promise<ReactElement> {
345
+ const { locale } = await params;
346
+ return renderMagnetLanding({ slug: ref.slug, path: ref.path, locale });
347
+ };
348
+ }
349
+
350
+ export function magnetLandingMetadata(ref: MagnetRef) {
351
+ return async function generateMetadata({ params }: MagnetPageProps): Promise<Metadata> {
352
+ const { locale } = await params;
353
+ return renderMagnetLandingMetadata({ slug: ref.slug, locale });
354
+ };
355
+ }
356
+
357
+ export function magnetStart(ref: MagnetRef) {
358
+ return async function MagnetStartPage({ params }: MagnetPageProps): Promise<ReactElement> {
359
+ const { locale } = await params;
360
+ return renderMagnetStart({ slug: ref.slug, locale, backHref: ref.path });
361
+ };
362
+ }
363
+
364
+ export function magnetStartMetadata(ref: MagnetRef) {
365
+ return async function generateMetadata({ params }: MagnetPageProps): Promise<Metadata> {
366
+ const { locale } = await params;
367
+ return renderMagnetStartMetadata({ slug: ref.slug, locale });
368
+ };
369
+ }
@@ -1,6 +1,8 @@
1
1
  // oss: false
2
2
  /**
3
- * /[locale]/flows/[slug] — the tool's LANDING route (#5053 D3/D5).
3
+ * /[locale]/flows/[slug] — the tool's LANDING route (#5053 D3/D5). Standard
4
+ * magnet page (#8840): rendered by `magnet-page.tsx`'s `renderMagnetLanding`
5
+ * with the pair's path `/flows/<slug>`.
4
6
  *
5
7
  * An interactive tool is TWO routes. This is the indexed half: the document
6
8
  * that describes the tool, carries its structured data, and links to the
@@ -18,92 +20,29 @@
18
20
  * resting on #5019, which names `assessment` and `calculator` among the
19
21
  * builders that keep that shape byte for byte).
20
22
  *
21
- * Copy: a served `FlowConfig` carries no tool-level name or description, so
22
- * the landing derives its name from the flow's own first-step title — the
23
- * same string this route already used as its page title before the split —
24
- * and takes its supporting line and CTA label from the site's `flow`
25
- * message namespace. No consuming site's wording ships here.
26
- *
27
- * Same `flows.get` fetch and same 404-on-`!res.ok` as the flow route: a flow
28
- * that is absent or unconfigured has no landing either. Dynamic rendering
29
- * for the same reason — the config is DB-backed live data.
23
+ * Copy (#8840 D8): the flow record's `landing` block — its title, line, meta
24
+ * line and button — else the flow's own first-step title and the site's
25
+ * `flow` messages. No consuming site's wording ships here. A flow that is
26
+ * absent has no landing either; a hub that cannot be reached shows the site's
27
+ * contact email. Dynamic rendering — the config is DB-backed live data.
30
28
  */
31
- import { flows } from "@working-theory/api-client";
32
- import { JsonLd } from "@working-theory/jsonld";
33
- import { buildJsonLd } from "@working-theory/schema";
34
- import { SectionRenderer } from "@working-theory/ui/sections";
35
- import type { FlowConfig, PageSection } from "@working-theory/validation";
36
- import { resolveLocalizedString } from "@working-theory/validation";
37
29
  import type { Metadata } from "next";
38
- import { notFound } from "next/navigation";
39
- import { getTranslations } from "next-intl/server";
40
30
  import type { ReactElement } from "react";
41
31
 
32
+ import { renderMagnetLanding, renderMagnetLandingMetadata } from "./magnet-page";
33
+
42
34
  export const dynamic = "force-dynamic";
43
35
 
44
36
  interface Props {
45
37
  params: Promise<{ locale: string; slug: string }>;
46
38
  }
47
39
 
48
- /** The tool's name — the flow's own first-step title in the active locale. */
49
- function toolName(config: FlowConfig, locale: string): string {
50
- return resolveLocalizedString(
51
- config.steps[0]!.copy.title,
52
- locale,
53
- config.defaultLocale,
54
- );
55
- }
56
-
57
40
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
58
41
  const { locale, slug } = await params;
59
- const res = await flows.get(slug);
60
- if (!res.ok) return {};
61
-
62
- const t = await getTranslations({ locale, namespace: "flow" });
63
- return {
64
- title: toolName(res.body, locale),
65
- description: t("landingIntro"),
66
- };
42
+ return renderMagnetLandingMetadata({ slug, locale });
67
43
  }
68
44
 
69
45
  export default async function Page({ params }: Props): Promise<ReactElement> {
70
46
  const { locale, slug } = await params;
71
- const res = await flows.get(slug);
72
- if (!res.ok) {
73
- notFound();
74
- }
75
-
76
- const t = await getTranslations({ locale, namespace: "flow" });
77
- const name = toolName(res.body, locale);
78
- const intro = t("landingIntro");
79
- const startHref = `/flows/${res.body.slug}/start`;
80
-
81
- const sections: PageSection[] = [
82
- {
83
- id: "flow-landing-hero",
84
- type: "hero",
85
- data: {
86
- headline: name,
87
- subheadline: intro,
88
- primaryCta: { label: t("getStarted"), href: startHref },
89
- },
90
- },
91
- ];
92
-
93
- return (
94
- <>
95
- <JsonLd
96
- schema={buildJsonLd({
97
- type: "Assessment",
98
- slug: res.body.slug,
99
- title: name,
100
- description: intro,
101
- locale,
102
- })}
103
- />
104
- <main>
105
- <SectionRenderer sections={sections} locale={locale} />
106
- </main>
107
- </>
108
- );
47
+ return renderMagnetLanding({ slug, path: `/flows/${slug}`, locale });
109
48
  }
@@ -1,23 +1,19 @@
1
1
  // oss: false
2
2
  /**
3
3
  * /[locale]/flows/[slug]/start — the flow half of the two-route tool
4
- * (#5053 D3/D5). Moved here from `../page.tsx`, which is now the indexed
5
- * landing; the fetch, the 404-on-`!res.ok` and the dynamic rendering are
6
- * unchanged from #3957 D8.
4
+ * (#5053 D3/D5). Standard magnet page (#8840): rendered by
5
+ * `magnet-page.tsx`'s `renderMagnetStart`, chrome-free with the back arrow to
6
+ * `/flows/<slug>` (`lib/magnet-routes.ts`).
7
7
  *
8
- * `flowRouteMetadata` (`@working-theory/metadata`) supplies `noindex,
9
- * follow` — the whole point of D4 is that this route does NOT write the
10
- * robots value, it gets it from what it mounts. `follow`, not `nofollow`:
11
- * the links out of a flow still carry.
8
+ * `flowRouteMetadata` (`@working-theory/metadata`, mounted by the renderer)
9
+ * supplies `noindex, follow` — the whole point of D4 is that this route does
10
+ * NOT write the robots value, it gets it from what it mounts. `follow`, not
11
+ * `nofollow`: the links out of a flow still carry.
12
12
  */
13
- import { flows } from "@working-theory/api-client";
14
- import { flowRouteMetadata } from "@working-theory/metadata";
15
- import { resolveLocalizedString } from "@working-theory/validation";
16
13
  import type { Metadata } from "next";
17
- import { notFound } from "next/navigation";
18
14
  import type { ReactElement } from "react";
19
15
 
20
- import { FlowStepperClient } from "../FlowStepperClient";
16
+ import { renderMagnetStart, renderMagnetStartMetadata } from "../magnet-page";
21
17
 
22
18
  export const dynamic = "force-dynamic";
23
19
 
@@ -27,27 +23,10 @@ interface Props {
27
23
 
28
24
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
29
25
  const { locale, slug } = await params;
30
- const res = await flows.get(slug);
31
- if (!res.ok) return flowRouteMetadata();
32
-
33
- const firstStep = res.body.steps[0];
34
- if (!firstStep) return flowRouteMetadata();
35
-
36
- return flowRouteMetadata({
37
- title: resolveLocalizedString(
38
- firstStep.copy.title,
39
- locale,
40
- res.body.defaultLocale,
41
- ),
42
- });
26
+ return renderMagnetStartMetadata({ slug, locale });
43
27
  }
44
28
 
45
29
  export default async function Page({ params }: Props): Promise<ReactElement> {
46
30
  const { locale, slug } = await params;
47
- const res = await flows.get(slug);
48
- if (!res.ok) {
49
- notFound();
50
- }
51
-
52
- return <FlowStepperClient config={res.body} locale={locale} slug={slug} />;
31
+ return renderMagnetStart({ slug, locale, backHref: `/flows/${slug}` });
53
32
  }
@@ -39,6 +39,7 @@ import {
39
39
  } from "@working-theory/ui/chrome";
40
40
  import {
41
41
  cookiePurpose,
42
+ NEWSLETTER_FLOW_SLUG,
42
43
  resolveConsentStatement,
43
44
  SITE_CONSENT_CONFIG_DEFAULTS,
44
45
  } from "@working-theory/validation";
@@ -58,13 +59,21 @@ import {
58
59
  SignedInNav,
59
60
  type SignedInNavProps,
60
61
  } from "../../components/signed-in-nav";
61
- import { isAccountPath, isAuthPath } from "../../lib/auth-routes";
62
+ import { isAccountPath, isAuthPath, isBookingPath } from "../../lib/auth-routes";
62
63
  import { isClosingFooterPath } from "../../lib/closing-footer-path";
64
+ import { pageOptsIntoDeck } from "../../lib/deck-page";
65
+ import {
66
+ inPageView,
67
+ newsletterSignup,
68
+ readCaptureStatus,
69
+ readServedMagnet,
70
+ } from "../../lib/in-page-magnet";
63
71
  // #8687 D5 — the per-page deck opt-in. Importing this module registers the
64
72
  // deck-page seam with page-renderer at module load (D3), the same
65
73
  // precedent as `../../lib/site-identity` and `../../lib/listings` below.
66
- import { pageOptsIntoDeck } from "../../lib/deck-page";
67
74
  import { assertEnabledLocale } from "../../lib/locale-guard";
75
+ import { isMagnetWalkPath } from "../../lib/magnet-routes";
76
+ import { SCHEDULING_PAGES } from "../../lib/modules.generated";
68
77
  import { footerColumns, navItems } from "../../lib/nav-config";
69
78
  import { getOgImageUrl } from "../../lib/og";
70
79
  import { backLinkHref } from "../../lib/profile-back-link";
@@ -268,6 +277,27 @@ interface Props {
268
277
  params: Promise<{ locale: string }>;
269
278
  }
270
279
 
280
+ /**
281
+ * #8847 D13 — the footer's newsletter signup: the newsletter magnet's compact
282
+ * `NewsletterFormInline`, only when the magnet is served (live), maps as a
283
+ * one-field signup, and the API accepts signups. The seeded built-in config
284
+ * never fills the footer. Both reads are shared for 15 seconds per instance.
285
+ */
286
+ async function footerNewsletter(locale: string): Promise<ReactElement | undefined> {
287
+ const [magnet, status] = await Promise.all([
288
+ readServedMagnet(NEWSLETTER_FLOW_SLUG),
289
+ readCaptureStatus(),
290
+ ]);
291
+ if (magnet === null || status?.newsletter !== true) return undefined;
292
+ const view = inPageView(magnet, locale, "newsletter");
293
+ if (view === null || !("kind" in view.form)) return undefined;
294
+ return (
295
+ <div data-testid="footer-newsletter">
296
+ {newsletterSignup({ ...view, form: view.form }, { locale, compact: true })}
297
+ </div>
298
+ );
299
+ }
300
+
271
301
  export default async function LocaleLayout({
272
302
  children,
273
303
  params,
@@ -291,6 +321,8 @@ export default async function LocaleLayout({
291
321
  // Null when signed out or auth is unset.
292
322
  const session = await readSession(await headers());
293
323
 
324
+ const footerSignup = await footerNewsletter(locale);
325
+
294
326
  // Resolve nav labels server-side so Nav + Footer components stay
295
327
  // i18n-hook-free and are testable without a NextIntlClientProvider wrapper.
296
328
  const tNav = await getTranslations({ locale, namespace: "nav" });
@@ -531,6 +563,22 @@ export default async function LocaleLayout({
531
563
  // fail-safe direction: an absent `x-pathname` keeps the full frame.
532
564
  const isAccountRoute = isAccountPath(pathname, enabledLocales);
533
565
 
566
+ // The booking branch (#8843 D10) — the scheduling module's /meet,
567
+ // /meet/<slug> and /private render chrome-free: the page draws its own
568
+ // back link (the index) or none (a meeting page). Only where the module is
569
+ // installed (`SCHEDULING_PAGES`), so a Free site's /meet placeholder keeps
570
+ // the marketing frame; /meet/confirmed keeps it too. The consent Banner
571
+ // stays, as on the auth branch. The same fail-safe direction: an absent
572
+ // `x-pathname` keeps the full frame.
573
+ const isBookingRoute = SCHEDULING_PAGES && isBookingPath(pathname, enabledLocales);
574
+
575
+ // #8840 D10 — a magnet's walk (`/<magnet>/start`, `/careers/apply`,
576
+ // `/careers/<posting>/apply`) renders full-screen: the auth branch's frame,
577
+ // no Nav and no Footer, and it KEEPS the cookie consent banner — a visitor
578
+ // can land straight on a walk, and analytics must not run on a page that
579
+ // never asked (SEC-8840-A). The same fail-safe direction.
580
+ const isMagnetWalkRoute = isMagnetWalkPath(pathname, enabledLocales);
581
+
534
582
  // #8687 D5 — a page whose frontmatter says `layout: deck` presents as the
535
583
  // deck home does, without the home's landing (no deckHero, no bare bar).
536
584
  // Never the home itself, and never on the auth or account branches, which
@@ -539,6 +587,7 @@ export default async function LocaleLayout({
539
587
  !isDeckHome &&
540
588
  !isAuthRoute &&
541
589
  !isAccountRoute &&
590
+ !isMagnetWalkRoute &&
542
591
  (await pageOptsIntoDeck({ pathname, locale }));
543
592
 
544
593
  // Every Nav prop that is safe to serialize — shared verbatim by the
@@ -717,7 +766,7 @@ export default async function LocaleLayout({
717
766
  * emitted unconditionally in OSS forks; paying customers install
718
767
  * @working-theory/branding and gate it in their own layout.
719
768
  */}
720
- {isAuthRoute || isAccountRoute ? (
769
+ {isAuthRoute || isAccountRoute || isBookingRoute || isMagnetWalkRoute ? (
721
770
  /*
722
771
  * The (auth) branch (#5963): the five auth paths render their own
723
772
  * `(auth)/layout.tsx` — AuthShell — with no Nav and no marketing
@@ -726,7 +775,8 @@ export default async function LocaleLayout({
726
775
  * favicons, NextIntlClientProvider, the consent Banner and
727
776
  * Telemetry) is shared by both branches and written once. The
728
777
  * account paths (#7430) take this branch too, and also skip the
729
- * consent Banner below.
778
+ * consent Banner below. The booking paths (#8843 D10) and a magnet
779
+ * walk (#8840 D10) take it and keep the Banner.
730
780
  */
731
781
  children
732
782
  ) : (
@@ -869,6 +919,7 @@ export default async function LocaleLayout({
869
919
  {...(isMinimalChrome ? { chromeStyle: "minimal" as const } : {})}
870
920
  // #7742 D2 — no theme switch under `colorMode: light`.
871
921
  showThemeToggle={!pinLight}
922
+ {...(footerSignup !== undefined && { subscribe: footerSignup })}
872
923
  bottomBarAlign="content"
873
924
  copyrightText={copyrightText}
874
925
  legalLinks={legalLinks}