create-ailk 0.20.0 → 0.22.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 +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -240,10 +240,12 @@ describe("structured data — every content page builds valid JSON-LD", () => {
|
|
|
240
240
|
"content/$locale/$typeDir — every page validates as its type",
|
|
241
241
|
async ({ locale, typeDir }) => {
|
|
242
242
|
if (!(SCHEMA_ORG_TYPES as readonly string[]).includes(typeDir)) {
|
|
243
|
-
// Identity + sub-entity types (Organization,
|
|
243
|
+
// Identity + sub-entity types (Organization, BreadcrumbList,
|
|
244
244
|
// ContactPoint) hold sub-objects other pages' builders embed — never
|
|
245
245
|
// rendered as pages, and skipped by tools/content-compile the same
|
|
246
|
-
// way. Anything else is a directory nothing will ever render.
|
|
246
|
+
// way. Anything else is a directory nothing will ever render. Person
|
|
247
|
+
// (#8650) is a registered page type (`/about/<slug>`), so it is
|
|
248
|
+
// covered by the SCHEMA_ORG_TYPES branch above, not this one.
|
|
247
249
|
if (!knownSchemaOrgTypes.has(typeDir)) {
|
|
248
250
|
throw new Error(
|
|
249
251
|
`content/${locale}/${typeDir}: "${typeDir}" is not a registered page type — no route or builder will ever render it (see packages/content-adapters/src/types.ts SCHEMA_ORG_TYPES)`,
|
|
@@ -295,11 +297,13 @@ describe("structured data — every content page builds valid JSON-LD", () => {
|
|
|
295
297
|
* exempt route whose page.tsx does render through `renderPage` fails too,
|
|
296
298
|
* because the reason given for it is false.
|
|
297
299
|
*/
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
|
|
300
|
+
// The marker both standard spoke checkout pages carry in their header comment
|
|
301
|
+
// (apps/web/app/[locale]/checkout/{thank-you,unavailable}/page.tsx, #8748 D5).
|
|
302
|
+
const CHECKOUT_PAGE_MARKER = "Standard spoke checkout page (#8748)";
|
|
303
|
+
// The marker create-ailk rendered before #8748. A spoke scaffolded then keeps
|
|
304
|
+
// its rendered pages through `create-ailk update` (they are not in its
|
|
305
|
+
// record), so its updated copy of this test must still exempt them.
|
|
306
|
+
const LEGACY_CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
|
|
303
307
|
const CHECKOUT_PAGE_NOINDEX_LINE = "robots: { index: false, follow: false }";
|
|
304
308
|
|
|
305
309
|
const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
@@ -311,9 +315,10 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
|
311
315
|
* entry naming no strings applies to its route unconditionally, as every
|
|
312
316
|
* entry did before. Keys the exemption on the file, not the topology seam:
|
|
313
317
|
* a spoke can carry a hand-written page at this route, and a site can
|
|
314
|
-
* edit the generated one — either is checked like any other route.
|
|
318
|
+
* edit the generated one — either is checked like any other route. A
|
|
319
|
+
* nested list is any one of its strings.
|
|
315
320
|
*/
|
|
316
|
-
requires?: readonly string[]
|
|
321
|
+
requires?: ReadonlyArray<string | readonly string[]>;
|
|
317
322
|
}> = [
|
|
318
323
|
// Fumadocs routes: JSON-LD is emitted by @working-theory/fumadocs-schema
|
|
319
324
|
// from the fumadocs `.source` collection, which is not built in this
|
|
@@ -386,22 +391,28 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
|
386
391
|
reason: "auth surfaces, not content pages",
|
|
387
392
|
subtree: true,
|
|
388
393
|
},
|
|
389
|
-
// #8513 —
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
+
// #8513 — the two standard spoke checkout return pages (#8748 D5):
|
|
395
|
+
// noindex, rendered through SectionRenderer directly, no JSON-LD. Exempt
|
|
396
|
+
// only when the page on disk still carries both the standard's marker and
|
|
397
|
+
// the noindex line (see `requires` above) — a hand-written or edited page
|
|
398
|
+
// at either route is checked like any other.
|
|
394
399
|
{
|
|
395
400
|
route: "checkout/thank-you",
|
|
396
401
|
reason:
|
|
397
|
-
"spoke checkout return page (
|
|
398
|
-
requires: [
|
|
402
|
+
"spoke checkout return page (#8748) — noindex, renders sections only, no JSON-LD",
|
|
403
|
+
requires: [
|
|
404
|
+
[CHECKOUT_PAGE_MARKER, LEGACY_CHECKOUT_PAGE_MARKER],
|
|
405
|
+
CHECKOUT_PAGE_NOINDEX_LINE,
|
|
406
|
+
],
|
|
399
407
|
},
|
|
400
408
|
{
|
|
401
409
|
route: "checkout/unavailable",
|
|
402
410
|
reason:
|
|
403
|
-
"spoke checkout return page (
|
|
404
|
-
requires: [
|
|
411
|
+
"spoke checkout return page (#8748) — noindex, renders sections only, no JSON-LD",
|
|
412
|
+
requires: [
|
|
413
|
+
[CHECKOUT_PAGE_MARKER, LEGACY_CHECKOUT_PAGE_MARKER],
|
|
414
|
+
CHECKOUT_PAGE_NOINDEX_LINE,
|
|
415
|
+
],
|
|
405
416
|
},
|
|
406
417
|
];
|
|
407
418
|
|
|
@@ -437,7 +448,9 @@ function exemption(route: string, src: string): string | undefined {
|
|
|
437
448
|
if (entry === undefined) return undefined;
|
|
438
449
|
if (
|
|
439
450
|
entry.requires !== undefined &&
|
|
440
|
-
!entry.requires.every((s) =>
|
|
451
|
+
!entry.requires.every((s) =>
|
|
452
|
+
typeof s === "string" ? src.includes(s) : s.some((any) => src.includes(any)),
|
|
453
|
+
)
|
|
441
454
|
) {
|
|
442
455
|
return undefined;
|
|
443
456
|
}
|
|
@@ -643,6 +656,12 @@ describe("structured data — exemption(route, src) keys the generated checkout
|
|
|
643
656
|
expect(exemption("checkout/unavailable", NO_NOINDEX_SRC)).toBeUndefined();
|
|
644
657
|
});
|
|
645
658
|
|
|
659
|
+
it("still exempts a page create-ailk rendered before #8748, which update leaves in place", () => {
|
|
660
|
+
const legacy = `// The checkout thank-you page — ${LEGACY_CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
|
|
661
|
+
expect(exemption("checkout/thank-you", legacy)).toContain("noindex");
|
|
662
|
+
expect(exemption("checkout/unavailable", legacy)).toContain("noindex");
|
|
663
|
+
});
|
|
664
|
+
|
|
646
665
|
it("does not exempt a neighboring checkout route or an unrelated route", () => {
|
|
647
666
|
expect(exemption("checkout/other", GENERATED_SRC)).toBeUndefined();
|
|
648
667
|
expect(exemption("about", GENERATED_SRC)).toBeUndefined();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* account-invite.ts — the /contact thank-you's account link (#8757 D8): the
|
|
3
|
+
* path that starts sign-in on this site with the submitter's email prefilled
|
|
4
|
+
* and lands on their account. It signs no one in; the email is only the
|
|
5
|
+
* sign-in form's starting value (NFR2).
|
|
6
|
+
*
|
|
7
|
+
* Built from the generated sign-in provider (`SIGN_IN_PROVIDER`):
|
|
8
|
+
* - `site` — this site's own `/sign-in`, locale-prefixed like every page
|
|
9
|
+
* (`localeHref`), with `next` = the locale's `/account` and `email`;
|
|
10
|
+
* - `hub` — a hub-sign-in spoke starts at `/api/hub-session/sign-in`
|
|
11
|
+
* (`accountInvitePath("hub", …)`), which hands the hint to the hub;
|
|
12
|
+
* - `none` — no sign-in, so no link.
|
|
13
|
+
* An email the hint schema refuses (`accountEmailHintSchema`) gives no link.
|
|
14
|
+
* The query is built with `URLSearchParams` (SEC-8757-L).
|
|
15
|
+
*/
|
|
16
|
+
import { localeHref } from "@working-theory/ui";
|
|
17
|
+
import { accountEmailHintSchema, accountInvitePath } from "@working-theory/validation";
|
|
18
|
+
|
|
19
|
+
import { SIGN_IN_PROVIDER } from "./topology.generated";
|
|
20
|
+
|
|
21
|
+
export function accountInviteHref(locale: string, email: string): string | null {
|
|
22
|
+
const hint = accountEmailHintSchema.safeParse(email);
|
|
23
|
+
if (!hint.success) return null;
|
|
24
|
+
switch (SIGN_IN_PROVIDER) {
|
|
25
|
+
case "site": {
|
|
26
|
+
const query = new URLSearchParams({
|
|
27
|
+
next: localeHref(locale, "/account"),
|
|
28
|
+
email: hint.data,
|
|
29
|
+
});
|
|
30
|
+
return `${localeHref(locale, "/sign-in")}?${query.toString()}`;
|
|
31
|
+
}
|
|
32
|
+
case "hub":
|
|
33
|
+
return accountInvitePath("hub", hint.data);
|
|
34
|
+
default:
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// checkout-config.ts — this spoke's checkout, as data (#7838, #8748).
|
|
2
|
+
//
|
|
3
|
+
// The one checkout file this site edits. Written once by create-ailk and yours
|
|
4
|
+
// from then on: `create-ailk update` never rewrites it. The standard checkout
|
|
5
|
+
// (lib/checkout.ts, the buy route and the Stripe webhook route) reads only
|
|
6
|
+
// `site` and `offerings` from it and is kept current by `create-ailk update`.
|
|
7
|
+
// Keep it to data: no import, and no code.
|
|
8
|
+
//
|
|
9
|
+
// site Stamped on every Checkout Session as metadata.site. This site's
|
|
10
|
+
// webhook acts only on its own stamp, and the hub ignores any stamp
|
|
11
|
+
// that is not its own, so the stamp must be unique on the Stripe
|
|
12
|
+
// account this site shares. Never "default". Change it only before
|
|
13
|
+
// any price exists: every lookup key starts with it.
|
|
14
|
+
// offerings slug -> Stripe lookup_key. The buy link is /api/checkout/<slug>,
|
|
15
|
+
// and any other slug answers 404. Name an offering's slug after the
|
|
16
|
+
// last segment of the Service or Product page that sells it: that
|
|
17
|
+
// is the key the hub's plan rule and a magnet's checkoutOffering
|
|
18
|
+
// use. scaffold-commerce replaces the placeholder below.
|
|
19
|
+
const SITE = "example-site";
|
|
20
|
+
|
|
21
|
+
export const checkoutConfig = {
|
|
22
|
+
site: SITE,
|
|
23
|
+
offerings: {
|
|
24
|
+
// A placeholder until scaffold-commerce creates this site's prices.
|
|
25
|
+
example: { kind: "fixed", lookupKey: `${SITE}_example`, cancelPath: "/" },
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// checkout.ts — the standard spoke checkout (#8748).
|
|
2
|
+
//
|
|
3
|
+
// The standard every selling spoke shares, kept current by `create-ailk
|
|
4
|
+
// update`: do not edit it. This site's checkout is data in
|
|
5
|
+
// lib/checkout-config.ts, the one file to edit. Only the buy route and the
|
|
6
|
+
// Stripe webhook route import this file, and it never imports the checkout
|
|
7
|
+
// package: every guarantee lives there and arrives by a package bump.
|
|
8
|
+
//
|
|
9
|
+
// spokeCheckoutConfig What the two routes build their handlers from. It reads
|
|
10
|
+
// only `site` and `offerings` from the site's file, so no
|
|
11
|
+
// other key a site declares reaches the package.
|
|
12
|
+
// fulfil Runs for each paid session, at least once. It posts one
|
|
13
|
+
// lead to the hub with the session id as its
|
|
14
|
+
// Idempotency-Key, so a repeat writes nothing new. The hub
|
|
15
|
+
// dedupes only an identical body: change the payload's
|
|
16
|
+
// shape only when no webhook is failing. `termsAccepted`
|
|
17
|
+
// is the stamp of the terms the buyer ticked in Stripe's
|
|
18
|
+
// box, else null: the hub records the buyer's checkout
|
|
19
|
+
// consent grant from it (#8778).
|
|
20
|
+
import { leads } from "@working-theory/api-client";
|
|
21
|
+
|
|
22
|
+
import { resolveBaseUrl } from "../src/lib/routes";
|
|
23
|
+
|
|
24
|
+
import { checkoutConfig } from "./checkout-config";
|
|
25
|
+
import { TOPOLOGY } from "./topology.generated";
|
|
26
|
+
|
|
27
|
+
/** What the fulfilment reads from a completed checkout. */
|
|
28
|
+
interface CompletedCheckout {
|
|
29
|
+
idempotencyKey: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
offering: string;
|
|
32
|
+
kind: "fixed" | "quoted";
|
|
33
|
+
mode: "test" | "live";
|
|
34
|
+
amountTotal: number | null;
|
|
35
|
+
currency: string | null;
|
|
36
|
+
email: string | null;
|
|
37
|
+
customerId: string | null;
|
|
38
|
+
termsOfService: "accepted" | null;
|
|
39
|
+
termsStamp: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fulfil(checkout: CompletedCheckout): Promise<void> {
|
|
43
|
+
if (checkout.email === null) return;
|
|
44
|
+
// No spoke key: the hub cannot place the lead in this site's workspace.
|
|
45
|
+
// Fail, so Stripe retries and the dashboard shows a failed webhook.
|
|
46
|
+
if (!process.env.AILK_SPOKE_KEY)
|
|
47
|
+
throw new Error("AILK_SPOKE_KEY is not set");
|
|
48
|
+
const result = await leads.submit(
|
|
49
|
+
{
|
|
50
|
+
siteId: checkoutConfig.site,
|
|
51
|
+
source: "web",
|
|
52
|
+
email: checkout.email,
|
|
53
|
+
payload: {
|
|
54
|
+
site: checkoutConfig.site,
|
|
55
|
+
page: "checkout",
|
|
56
|
+
offering: checkout.offering,
|
|
57
|
+
checkoutKind: checkout.kind,
|
|
58
|
+
mode: checkout.mode,
|
|
59
|
+
amountTotal: checkout.amountTotal,
|
|
60
|
+
currency: checkout.currency,
|
|
61
|
+
stripeCustomerId: checkout.customerId,
|
|
62
|
+
checkoutSession: checkout.sessionId,
|
|
63
|
+
termsAccepted:
|
|
64
|
+
checkout.termsOfService === "accepted" ? checkout.termsStamp : null,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{ headers: { "Idempotency-Key": checkout.idempotencyKey } },
|
|
68
|
+
);
|
|
69
|
+
// A replay under the same key answers 201 with the first lead; any
|
|
70
|
+
// failure (422 idempotency_key_reused included) makes Stripe retry.
|
|
71
|
+
if (!result.ok) throw new Error(`hub lead write answered ${result.status}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The config both routes pass to the checkout package, built key by key. */
|
|
75
|
+
export const spokeCheckoutConfig = {
|
|
76
|
+
site: checkoutConfig.site,
|
|
77
|
+
offerings: checkoutConfig.offerings,
|
|
78
|
+
siteBaseUrl: resolveBaseUrl(),
|
|
79
|
+
fulfil,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Whether this build serves the checkout: a spoke only (#8748 D11). */
|
|
83
|
+
export function checkoutServed(): boolean {
|
|
84
|
+
return TOPOLOGY === "spoke";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What either route answers on a build that does not serve the checkout. */
|
|
88
|
+
export function checkoutNotServed(): Response {
|
|
89
|
+
return new Response(null, {
|
|
90
|
+
status: 404,
|
|
91
|
+
headers: { "Cache-Control": "no-store" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consent-policies.ts — the one policy map an agreement surface links
|
|
3
|
+
* through (#8765 D9, spec docs/specs/8765-sign-up-refuses-without-the-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* A consent statement names its links by key (`[Terms](terms)`); the keys
|
|
6
|
+
* resolve against the registry's `policies` map. This localizes that map, so
|
|
7
|
+
* the sign-up page, the account interstitial and the Privacy tab link Terms,
|
|
8
|
+
* Privacy Policy and Working Agreement to the same three pages `/contact`
|
|
9
|
+
* builds inline (#8761 decision, C6).
|
|
10
|
+
*/
|
|
11
|
+
import { localeHref } from "@working-theory/ui";
|
|
12
|
+
import { SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
|
|
13
|
+
|
|
14
|
+
/** The registry's policy map with each path localized for `locale`. */
|
|
15
|
+
export function consentPoliciesFor(locale: string): Record<string, string> {
|
|
16
|
+
return Object.fromEntries(
|
|
17
|
+
Object.entries(SITE_CONSENT_CONFIG_DEFAULTS.policies).map(([key, path]) => [
|
|
18
|
+
key,
|
|
19
|
+
localeHref(locale, path),
|
|
20
|
+
]),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deck-page resolver (#8687 D3) — the layout's question ("does the page
|
|
3
|
+
* at this path opt in?") and `renderPage`'s consult both ask this ONE
|
|
4
|
+
* resolver, so they always agree. Registers with `@working-theory/page-renderer`
|
|
5
|
+
* at module load, in `lib/site-identity.ts`'s precedent: `app/[locale]/layout.tsx`
|
|
6
|
+
* imports this module, and Next.js evaluates a layout module before any page
|
|
7
|
+
* under it renders.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
getMdxSource as defaultGetMdxSource,
|
|
11
|
+
optsIntoDeck,
|
|
12
|
+
setDeckPages,
|
|
13
|
+
type DeckPageContext,
|
|
14
|
+
type DeckPageInputs,
|
|
15
|
+
type SchemaOrgType,
|
|
16
|
+
} from "@working-theory/page-renderer";
|
|
17
|
+
import { ROUTES, type RouteRow } from "@working-theory/templates/routes";
|
|
18
|
+
import { readLocales } from "@working-theory/web-i18n";
|
|
19
|
+
import { headers } from "next/headers";
|
|
20
|
+
import { cache } from "react";
|
|
21
|
+
|
|
22
|
+
import { normalizeRoutePath } from "./closing-footer-path";
|
|
23
|
+
import { siteChrome, type SiteChrome } from "./site-chrome";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The routes besides `[slug]` whose page can present as a deck: every
|
|
27
|
+
* routes-map row with seed content whose route renders sections, minus `/`.
|
|
28
|
+
*
|
|
29
|
+
* This is a fixed superset across every shape/level a scaffold can ship,
|
|
30
|
+
* not a per-site exact list: a shape that doesn't carry one of these routes
|
|
31
|
+
* (e.g. a Free scaffold without `/alternatives`) has no `page.tsx` there, so
|
|
32
|
+
* `deckCandidates`/`resolveDeckPage` never get a request for that path —
|
|
33
|
+
* harmless. `apps/web/lib/__tests__/deck-page.test.ts`'s parity case checks
|
|
34
|
+
* the direction that would be a real bug: that this list names every
|
|
35
|
+
* sections route THIS build's own tree actually carries.
|
|
36
|
+
*/
|
|
37
|
+
export const DECK_ROUTES = [
|
|
38
|
+
"/about",
|
|
39
|
+
"/alternatives",
|
|
40
|
+
"/contact",
|
|
41
|
+
"/faq",
|
|
42
|
+
"/launch-service",
|
|
43
|
+
"/pricing",
|
|
44
|
+
"/privacy",
|
|
45
|
+
"/projects",
|
|
46
|
+
"/projects/:slug",
|
|
47
|
+
"/purchase-policy",
|
|
48
|
+
"/reports",
|
|
49
|
+
"/terms",
|
|
50
|
+
"/thanks/:slug",
|
|
51
|
+
"/working-agreement",
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
/** The widened type (#7719 D3, #7740 D8): present only when the design sets it. */
|
|
55
|
+
type Chrome = SiteChrome & {
|
|
56
|
+
deckGrounds?: DeckPageInputs["deckGrounds"];
|
|
57
|
+
deckPresentation?: true;
|
|
58
|
+
/** #8773 — present only when the design chooses bare slide anchors. */
|
|
59
|
+
deckAnchors?: "bare";
|
|
60
|
+
};
|
|
61
|
+
const chrome: Chrome = siteChrome;
|
|
62
|
+
|
|
63
|
+
function routeSegments(route: string): string[] {
|
|
64
|
+
return route.split("/").filter((segment) => segment.length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolean {
|
|
68
|
+
const defSegments = routeSegments(routeDef);
|
|
69
|
+
if (defSegments.length !== pathSegments.length) return false;
|
|
70
|
+
return defSegments.every(
|
|
71
|
+
(segment, i) => segment.startsWith(":") || segment === pathSegments[i],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The type and slug a matched routes-map row resolves to for this request. */
|
|
76
|
+
function resolveTypeAndSlug(
|
|
77
|
+
row: RouteRow,
|
|
78
|
+
routeDef: string,
|
|
79
|
+
pathSegments: readonly string[],
|
|
80
|
+
): { type: SchemaOrgType; slug: string } | null {
|
|
81
|
+
if (row.contentPath === undefined) return null;
|
|
82
|
+
const parts = row.contentPath.split("/");
|
|
83
|
+
const last = parts[parts.length - 1];
|
|
84
|
+
if (last === undefined) return null;
|
|
85
|
+
const isFile = last.endsWith(".mdx");
|
|
86
|
+
const type = isFile ? parts[parts.length - 2] : last;
|
|
87
|
+
if (type === undefined) return null;
|
|
88
|
+
const slug = isFile
|
|
89
|
+
? last.replace(/\.mdx$/, "")
|
|
90
|
+
: pathSegments[routeSegments(routeDef).findIndex((segment) => segment.startsWith(":"))];
|
|
91
|
+
if (slug === undefined) return null;
|
|
92
|
+
return { type: type as SchemaOrgType, slug };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether a routes-map row at this exact route is always scaffolded (never absent from a site). */
|
|
96
|
+
function isAlwaysScaffolded(path: string): boolean {
|
|
97
|
+
const row = ROUTES.find((candidate) => candidate.route === path);
|
|
98
|
+
return row !== undefined && row.gateable !== true && row.module === "marketing";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The content pages that could serve `pathname`, in the order Next.js tries
|
|
103
|
+
* them (D3). `/` gives none — the home's deck is its own `layout.home` knob,
|
|
104
|
+
* never this resolver's.
|
|
105
|
+
*/
|
|
106
|
+
export function deckCandidates(
|
|
107
|
+
pathname: string,
|
|
108
|
+
locales: readonly string[],
|
|
109
|
+
): Array<{ type: SchemaOrgType; slug: string }> {
|
|
110
|
+
const path = normalizeRoutePath(pathname, locales);
|
|
111
|
+
if (path === "/") return [];
|
|
112
|
+
const segments = routeSegments(path);
|
|
113
|
+
const candidates: Array<{ type: SchemaOrgType; slug: string }> = [];
|
|
114
|
+
|
|
115
|
+
for (const routeDef of DECK_ROUTES) {
|
|
116
|
+
if (!matchesRoute(routeDef, segments)) continue;
|
|
117
|
+
const row = ROUTES.find((candidate) => candidate.route === routeDef);
|
|
118
|
+
const resolved = row && resolveTypeAndSlug(row, routeDef, segments);
|
|
119
|
+
if (resolved) candidates.push(resolved);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A one-segment path not always scaffolded falls through to `[slug]`,
|
|
124
|
+
// which serves `LandingPage` then `BlogList` (D3).
|
|
125
|
+
if (segments.length === 1 && !isAlwaysScaffolded(path)) {
|
|
126
|
+
const slug = segments[0];
|
|
127
|
+
if (slug !== undefined) {
|
|
128
|
+
candidates.push({ type: "LandingPage", slug });
|
|
129
|
+
candidates.push({ type: "BlogList", slug });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return candidates;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The first candidate with a source, and its frontmatter, or `null`. A read
|
|
138
|
+
* that throws ends the walk as `null` (NFR 3) — the page's own render
|
|
139
|
+
* surfaces that error, and the layout keeps its plain shell. Per-request
|
|
140
|
+
* memoised (React `cache`) — the layout and `renderPage`'s consult ask the
|
|
141
|
+
* same question for the same request and must share one answer.
|
|
142
|
+
*/
|
|
143
|
+
export const resolveDeckPage = cache(async function resolveDeckPage(
|
|
144
|
+
pathname: string,
|
|
145
|
+
locale: string,
|
|
146
|
+
): Promise<{ type: SchemaOrgType; slug: string; frontmatter: Record<string, unknown> } | null> {
|
|
147
|
+
for (const candidate of deckCandidates(pathname, readLocales())) {
|
|
148
|
+
let source;
|
|
149
|
+
try {
|
|
150
|
+
source = await defaultGetMdxSource(candidate.type, candidate.slug, locale);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (source) return { ...candidate, frontmatter: source.frontmatter };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** The layout's question: does the page at this path opt in? */
|
|
160
|
+
export async function pageOptsIntoDeck(input: {
|
|
161
|
+
pathname: string;
|
|
162
|
+
locale: string;
|
|
163
|
+
}): Promise<boolean> {
|
|
164
|
+
const resolved = await resolveDeckPage(input.pathname, input.locale);
|
|
165
|
+
return resolved !== null && optsIntoDeck(resolved.frontmatter);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `inputsFor`: only when this render IS the page the request path resolves to and it opts in. */
|
|
169
|
+
async function inputsFor(ctx: DeckPageContext): Promise<DeckPageInputs | null> {
|
|
170
|
+
const headerStore = await headers();
|
|
171
|
+
const pathname = headerStore.get("x-pathname") ?? null;
|
|
172
|
+
if (pathname === null) return null;
|
|
173
|
+
const resolved = await resolveDeckPage(pathname, ctx.locale);
|
|
174
|
+
if (
|
|
175
|
+
resolved === null ||
|
|
176
|
+
resolved.type !== ctx.type ||
|
|
177
|
+
resolved.slug !== ctx.slug ||
|
|
178
|
+
!optsIntoDeck(resolved.frontmatter)
|
|
179
|
+
) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
|
|
184
|
+
deckPresentation: chrome.deckPresentation === true,
|
|
185
|
+
// #8773 — a page that presents names its slides as the design chose.
|
|
186
|
+
...(chrome.deckPresentation === true && chrome.deckAnchors === "bare"
|
|
187
|
+
? { deckAnchorStyle: "bare" as const }
|
|
188
|
+
: {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
setDeckPages({ inputsFor });
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HubSignInGate (#7894 D10) as #8790 AC-1/AC-2 shape it: the site's inner-page
|
|
3
|
+
* header (back arrow + mark), a label-size heading, the one hub sign-in
|
|
4
|
+
* action, and a prospect line only when the site's messages carry it.
|
|
5
|
+
*/
|
|
6
|
+
import { render, screen } from "@testing-library/react";
|
|
7
|
+
import en from "../../../messages/en.json";
|
|
8
|
+
import { HubSignInGate } from "../sign-in-gate";
|
|
9
|
+
|
|
10
|
+
type Gate = Record<string, string>;
|
|
11
|
+
let gateMessages: Gate;
|
|
12
|
+
|
|
13
|
+
jest.mock("next-intl/server", () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
getTranslations: jest.fn(async () => {
|
|
16
|
+
const t = (key: string) => gateMessages[key] ?? `copy:${key}`;
|
|
17
|
+
t.has = (key: string) => key in gateMessages;
|
|
18
|
+
return t;
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const mockConfig = jest.fn();
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; hubSessionConfig() is a pure env read
|
|
24
|
+
jest.mock("../config", () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
hubSessionConfig: () => mockConfig(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; siteLogo() reads the site's brand files
|
|
30
|
+
jest.mock("../../site-logo", () => ({
|
|
31
|
+
__esModule: true,
|
|
32
|
+
siteLogo: () => (
|
|
33
|
+
<img src="/logomark.svg" alt="Site" data-testid="site-mark" />
|
|
34
|
+
),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
async function renderGate(pathname?: string): Promise<void> {
|
|
38
|
+
const requestHeaders = new Headers();
|
|
39
|
+
if (pathname) requestHeaders.set("x-pathname", pathname);
|
|
40
|
+
render(await HubSignInGate({ locale: "en", requestHeaders }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
gateMessages = { ...(en.account.gate as Gate) };
|
|
45
|
+
mockConfig.mockReset().mockReturnValue({ clientId: "spoke_1" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("<HubSignInGate/> (#8790 AC-1)", () => {
|
|
49
|
+
it("wears the site's inner-page header: a back arrow to the home and the mark", async () => {
|
|
50
|
+
await renderGate("/account");
|
|
51
|
+
const header = screen.getByTestId("account-sign-in-gate-header");
|
|
52
|
+
const back = screen.getByTestId("nav-back-link");
|
|
53
|
+
expect(header).toContainElement(back);
|
|
54
|
+
expect(back).toHaveAttribute("href", "/");
|
|
55
|
+
expect(back).toHaveAccessibleName(en.account.gate.homeLink);
|
|
56
|
+
expect(screen.getByTestId("account-sign-in-gate-mark")).toContainElement(
|
|
57
|
+
screen.getByTestId("site-mark"),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("sets the heading at the label size and offers the one sign-in action", async () => {
|
|
62
|
+
await renderGate("/account/settings");
|
|
63
|
+
const heading = screen.getByRole("heading", { level: 1 });
|
|
64
|
+
expect(heading).toHaveTextContent(en.account.gate.hubHeadline);
|
|
65
|
+
expect(heading).toHaveClass("text-heading-2");
|
|
66
|
+
expect(screen.getByTestId("account-hub-sign-in")).toHaveAttribute(
|
|
67
|
+
"href",
|
|
68
|
+
"/api/hub-session/sign-in?next=%2Faccount%2Fsettings",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders no prospect line by default (the shipped messages carry no prospect keys)", async () => {
|
|
73
|
+
await renderGate();
|
|
74
|
+
expect(screen.queryByTestId("account-sign-in-gate-prospect")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("<HubSignInGate/> prospect line (#8790 AC-2)", () => {
|
|
79
|
+
it("renders the line and its link when the site fills all three keys", async () => {
|
|
80
|
+
gateMessages.prospectPrompt = "New here?";
|
|
81
|
+
gateMessages.prospectLink = "See if we fit";
|
|
82
|
+
gateMessages.prospectHref = "/fit";
|
|
83
|
+
await renderGate();
|
|
84
|
+
const line = screen.getByTestId("account-sign-in-gate-prospect");
|
|
85
|
+
expect(line).toHaveTextContent("New here? See if we fit.");
|
|
86
|
+
expect(screen.getByRole("link", { name: "See if we fit" })).toHaveAttribute(
|
|
87
|
+
"href",
|
|
88
|
+
"/fit",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renders nothing when any of the three keys is absent", async () => {
|
|
93
|
+
gateMessages.prospectPrompt = "New here?";
|
|
94
|
+
gateMessages.prospectLink = "See if we fit";
|
|
95
|
+
await renderGate();
|
|
96
|
+
expect(screen.queryByTestId("account-sign-in-gate-prospect")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("keeps the not-configured state when the hub is not configured", async () => {
|
|
100
|
+
mockConfig.mockReturnValue(null);
|
|
101
|
+
await renderGate();
|
|
102
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
103
|
+
en.account.gate.emptyHeadline,
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByTestId("account-sign-in-gate-home")).toHaveAttribute(
|
|
106
|
+
"href",
|
|
107
|
+
"/",
|
|
108
|
+
);
|
|
109
|
+
expect(screen.queryByTestId("account-hub-sign-in")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|