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.
- package/README.md +27 -14
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +17 -10
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +301 -13
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +26 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +36 -2
- package/dist/lib/scaffold-render-sweep.js +50 -8
- package/dist/module-architecture.js +115 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +68 -5
- package/dist/sync-routes.js +455 -29
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +13 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +34 -0
- package/templates/apps/api/CLAUDE.md +94 -19
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +77 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +343 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +377 -308
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
- package/templates/apps/api/src/server.ts +165 -51
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +147 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +32 -18
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +324 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +44 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +205 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +63 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +57 -2
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -1,21 +1,324 @@
|
|
|
1
1
|
// Pricing page. Reads the Pricing MDX frontmatter, renders sections via
|
|
2
2
|
// SectionRenderer, and emits a JSON-LD <script>.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
//
|
|
4
|
+
// `?plan=<key>` (#8842) — a diagnostic's recommended plan. The key is handed
|
|
5
|
+
// to every `pricing` section as `recommended`; the section moves its authored
|
|
6
|
+
// recommendation onto the matching plan and scrolls it into view. Reading the
|
|
7
|
+
// query renders this page per request: the recommendation is in the served
|
|
8
|
+
// HTML, so the card never flashes from the authored plan to the named one.
|
|
9
|
+
//
|
|
10
|
+
// The Offerings shown set (#8927, spec
|
|
11
|
+
// docs/specs/8927-pricing-renders-the-shown-set-spec.md). Before rendering,
|
|
12
|
+
// the page reads `GET /v1/content/shown-offerings` through the api-client,
|
|
13
|
+
// shared per server instance for READ_TTL_MS, and renders one of three pages:
|
|
14
|
+
// - unconnected (or production with no API origin) — today's MDX page,
|
|
15
|
+
// exactly as above (D7);
|
|
16
|
+
// - a current shown set — the workspace's Stripe cards in place of the
|
|
17
|
+
// page's authored `pricing` sections, `?plan=` moving the recommendation
|
|
18
|
+
// only for a `{plan}`-grammar value (D4, contract §3.7);
|
|
19
|
+
// - anything else (expired, empty, a failed read) — the site's contact
|
|
20
|
+
// email in place of those sections, never an error (D5).
|
|
21
|
+
// Stripe text reaches markup only as React text and JSON-LD only through
|
|
22
|
+
// renderPage's serializer (T4); the metadata stays the MDX page's (T8).
|
|
23
|
+
import { offerings } from "@working-theory/api-client";
|
|
6
24
|
import {
|
|
25
|
+
getPageSections as defaultGetPageSections,
|
|
7
26
|
renderPage,
|
|
8
|
-
renderPageMetadata
|
|
27
|
+
renderPageMetadata,
|
|
9
28
|
} from "@working-theory/page-renderer";
|
|
29
|
+
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
30
|
+
import { ContactFallback } from "@working-theory/ui";
|
|
31
|
+
import {
|
|
32
|
+
LOOKUP_KEY_PLAN_PATTERN,
|
|
33
|
+
type PageSection,
|
|
34
|
+
} from "@working-theory/validation";
|
|
10
35
|
import type { Metadata } from "next";
|
|
36
|
+
import { getTranslations } from "next-intl/server";
|
|
11
37
|
import type { ReactElement } from "react";
|
|
12
38
|
|
|
39
|
+
import {
|
|
40
|
+
offeringsPricing,
|
|
41
|
+
type OfferingsPricing,
|
|
42
|
+
type OfferingsPricingLabels,
|
|
43
|
+
} from "../../../lib/offerings-pricing";
|
|
44
|
+
import { contactFormText } from "../contact/contact-form-text";
|
|
45
|
+
import { readSiteContactEmail } from "../contact/site-contact-email";
|
|
46
|
+
|
|
47
|
+
// #8927 D2 — never prerendered; the Offerings read behind it is shared for READ_TTL_MS.
|
|
48
|
+
export const dynamic = "force-dynamic";
|
|
49
|
+
|
|
13
50
|
interface Props {
|
|
14
51
|
params: Promise<{ locale: string }>;
|
|
52
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
15
53
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
16
54
|
getPageSections?: typeof defaultGetPageSections;
|
|
17
55
|
}
|
|
18
56
|
|
|
57
|
+
type PricingSection = Extract<PageSection, { type: "pricing" }>;
|
|
58
|
+
type PricingData = PricingSection["data"];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every `pricing` section with `recommended` set to `plan` (#8842). Each
|
|
62
|
+
* section matches the key against its own plans, so a second plan group
|
|
63
|
+
* keeps its own default unless it carries the same key.
|
|
64
|
+
*/
|
|
65
|
+
function withRecommendedPlan(
|
|
66
|
+
sections: PageSection[],
|
|
67
|
+
plan: string,
|
|
68
|
+
): PageSection[] {
|
|
69
|
+
return sections.map((section) =>
|
|
70
|
+
section.type === "pricing"
|
|
71
|
+
? {
|
|
72
|
+
...section,
|
|
73
|
+
// `recommended` is a render-time field the section reads, not an
|
|
74
|
+
// authored one, so it is not in the validated section schema.
|
|
75
|
+
data: { ...section.data, recommended: plan } as typeof section.data,
|
|
76
|
+
}
|
|
77
|
+
: section,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type OfferingsRead =
|
|
82
|
+
| { kind: "unconnected" }
|
|
83
|
+
| { kind: "shown"; shownSet: unknown }
|
|
84
|
+
| { kind: "contact" };
|
|
85
|
+
|
|
86
|
+
const CONTACT: OfferingsRead = { kind: "contact" };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How long the Offerings read is shared across requests (D2): a view carries
|
|
90
|
+
* no visitor headers, so every view would otherwise count against this
|
|
91
|
+
* server's one per-IP limit at the API. A product switched off disappears
|
|
92
|
+
* within it. The contact page's window (#8731).
|
|
93
|
+
*/
|
|
94
|
+
const READ_TTL_MS = 15_000;
|
|
95
|
+
|
|
96
|
+
const reads = new Map<string, { at: number; read: Promise<OfferingsRead> }>();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `read()`'s result, shared per instance for READ_TTL_MS. Concurrent calls
|
|
100
|
+
* share one in-flight read, and a failed read is shared the same way, so a
|
|
101
|
+
* burst against an unreachable API is one call.
|
|
102
|
+
*/
|
|
103
|
+
function sharedRead(
|
|
104
|
+
key: string,
|
|
105
|
+
read: () => Promise<OfferingsRead>,
|
|
106
|
+
): Promise<OfferingsRead> {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const hit = reads.get(key);
|
|
109
|
+
if (hit && now - hit.at < READ_TTL_MS) return hit.read;
|
|
110
|
+
const fresh = read();
|
|
111
|
+
reads.set(key, { at: now, read: fresh });
|
|
112
|
+
return fresh;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether an API origin is configured: the three names `getApiBase()` in
|
|
117
|
+
* packages/api-client/src/http.ts reads, in step with it. Without one that
|
|
118
|
+
* function answers `localhost:3001`.
|
|
119
|
+
*/
|
|
120
|
+
function apiOriginConfigured(): boolean {
|
|
121
|
+
return Boolean(
|
|
122
|
+
process.env.AILK_API_URL ||
|
|
123
|
+
process.env.NEXT_PUBLIC_AILK_API_URL ||
|
|
124
|
+
process.env.NEXT_PUBLIC_API_URL,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The shown-offerings read (D2). A production deployment with no API has no
|
|
130
|
+
* connection to read, so it is unconnected without a call. The body is
|
|
131
|
+
* untrusted: anything but `unconnected` or `shown` — `empty`, an unknown
|
|
132
|
+
* status, a non-object body, a non-2xx, a throw — is the contact email.
|
|
133
|
+
*/
|
|
134
|
+
function readShownOfferings(): Promise<OfferingsRead> {
|
|
135
|
+
if (process.env.NODE_ENV === "production" && !apiOriginConfigured()) {
|
|
136
|
+
return Promise.resolve({ kind: "unconnected" });
|
|
137
|
+
}
|
|
138
|
+
return sharedRead("shown-offerings", async () => {
|
|
139
|
+
try {
|
|
140
|
+
const res = await offerings.shown();
|
|
141
|
+
if (!res.ok) return CONTACT;
|
|
142
|
+
const body: unknown = res.body;
|
|
143
|
+
if (body === null || typeof body !== "object") return CONTACT;
|
|
144
|
+
const { status, shownSet } = body as {
|
|
145
|
+
status?: unknown;
|
|
146
|
+
shownSet?: unknown;
|
|
147
|
+
};
|
|
148
|
+
if (status === "unconnected") return { kind: "unconnected" };
|
|
149
|
+
if (status === "shown") return { kind: "shown", shownSet };
|
|
150
|
+
return CONTACT;
|
|
151
|
+
} catch {
|
|
152
|
+
return CONTACT;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Keeps the `{amount}` placeholder for `period` to fill (D6). */
|
|
158
|
+
const KEEP_AMOUNT_PLACEHOLDER = { amount: "{amount}" };
|
|
159
|
+
|
|
160
|
+
/** The mapper's labels and the plans section's recommendation label (D6). */
|
|
161
|
+
async function offeringsCopy(locale: string): Promise<{
|
|
162
|
+
labels: OfferingsPricingLabels;
|
|
163
|
+
recommendedLabel: string;
|
|
164
|
+
addonsHeadline: string;
|
|
165
|
+
}> {
|
|
166
|
+
const t = await getTranslations({ locale, namespace: "pricingOfferings" });
|
|
167
|
+
const period = (key: "monthly" | "annual" | "oneTime") => {
|
|
168
|
+
const phrase = t(key, KEEP_AMOUNT_PLACEHOLDER);
|
|
169
|
+
return (amount: string) => phrase.replace("{amount}", () => amount);
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
labels: {
|
|
173
|
+
cta: t("cta"),
|
|
174
|
+
recommended: t("recommended"),
|
|
175
|
+
monthly: period("monthly"),
|
|
176
|
+
annual: period("annual"),
|
|
177
|
+
oneTime: period("oneTime"),
|
|
178
|
+
},
|
|
179
|
+
recommendedLabel: t("recommendedForYou"),
|
|
180
|
+
addonsHeadline: t("addonsHeadline"),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The authored section copy a Stripe section keeps — never a plan or price fact. */
|
|
185
|
+
const AUTHORED_COPY_FIELDS = [
|
|
186
|
+
"eyebrow",
|
|
187
|
+
"headline",
|
|
188
|
+
"description",
|
|
189
|
+
"header",
|
|
190
|
+
"headlineSize",
|
|
191
|
+
"toggleStyle",
|
|
192
|
+
"featureListStyle",
|
|
193
|
+
"gapAbove",
|
|
194
|
+
"separation",
|
|
195
|
+
"gap",
|
|
196
|
+
"surface",
|
|
197
|
+
"note",
|
|
198
|
+
"collapseFeaturesOnMobile",
|
|
199
|
+
] as const satisfies readonly (keyof PricingData)[];
|
|
200
|
+
|
|
201
|
+
function authoredCopy(
|
|
202
|
+
section: PricingSection | undefined,
|
|
203
|
+
extra: readonly (keyof PricingData)[] = [],
|
|
204
|
+
): Partial<PricingData> {
|
|
205
|
+
const copy: Partial<Record<keyof PricingData, unknown>> = {};
|
|
206
|
+
if (!section) return copy as Partial<PricingData>;
|
|
207
|
+
for (const field of [...AUTHORED_COPY_FIELDS, ...extra]) {
|
|
208
|
+
if (section.data[field] !== undefined) copy[field] = section.data[field];
|
|
209
|
+
}
|
|
210
|
+
return copy as Partial<PricingData>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isPricing(section: PageSection): section is PricingSection {
|
|
214
|
+
return section.type === "pricing";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Where a section with no slot goes: after a leading hero, else first. */
|
|
218
|
+
function insertionIndex(sections: PageSection[]): number {
|
|
219
|
+
return sections[0]?.type === "hero" ? 1 : 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The page's sections with the Stripe fragments in place of its authored
|
|
224
|
+
* `pricing` sections (D4). The first non-`addons` pricing section is the
|
|
225
|
+
* plans slot and the first `addons` one the add-ons slot; every other pricing
|
|
226
|
+
* section, and a slot whose fragment is `null`, is removed. A fragment with no
|
|
227
|
+
* slot is inserted after a leading hero (the add-ons directly after the
|
|
228
|
+
* plans). No other section moves.
|
|
229
|
+
*/
|
|
230
|
+
function withOfferings(
|
|
231
|
+
sections: PageSection[],
|
|
232
|
+
pricing: OfferingsPricing,
|
|
233
|
+
plan: string | undefined,
|
|
234
|
+
recommendedLabel: string,
|
|
235
|
+
addonsHeadline: string,
|
|
236
|
+
): PageSection[] {
|
|
237
|
+
const plansSlot = sections.find(
|
|
238
|
+
(s): s is PricingSection => isPricing(s) && s.data.variant !== "addons",
|
|
239
|
+
);
|
|
240
|
+
const addonsSlot = sections.find(
|
|
241
|
+
(s): s is PricingSection => isPricing(s) && s.data.variant === "addons",
|
|
242
|
+
);
|
|
243
|
+
const recommend =
|
|
244
|
+
plan !== undefined && LOOKUP_KEY_PLAN_PATTERN.test(plan) ? plan : undefined;
|
|
245
|
+
const plansSection: PricingSection | null = pricing.plans && {
|
|
246
|
+
id: plansSlot?.id ?? "offerings-plans",
|
|
247
|
+
type: "pricing",
|
|
248
|
+
data: {
|
|
249
|
+
...authoredCopy(plansSlot),
|
|
250
|
+
...pricing.plans,
|
|
251
|
+
// A `?plan=` recommendation moves the mapper's highlight onto the named
|
|
252
|
+
// plan and wears `recommendedLabel` (#8842); the mapper's own
|
|
253
|
+
// "Recommended" pill label would ride along as a second label, so it is
|
|
254
|
+
// dropped from every card when a recommendation is in play.
|
|
255
|
+
...(recommend !== undefined &&
|
|
256
|
+
pricing.plans.plans.some(
|
|
257
|
+
(card) => card.key?.toLowerCase() === recommend.toLowerCase(),
|
|
258
|
+
)
|
|
259
|
+
? {
|
|
260
|
+
plans: pricing.plans.plans.map(({ highlightLabel: _, ...card }) => card),
|
|
261
|
+
}
|
|
262
|
+
: {}),
|
|
263
|
+
// Render-time fields the section reads (#8842), not authored ones.
|
|
264
|
+
recommendedLabel,
|
|
265
|
+
...(recommend !== undefined ? { recommended: recommend } : {}),
|
|
266
|
+
} as PricingData,
|
|
267
|
+
};
|
|
268
|
+
const addonsSection: PricingSection | null = pricing.addons && {
|
|
269
|
+
id: addonsSlot?.id ?? "offerings-addons",
|
|
270
|
+
type: "pricing",
|
|
271
|
+
data: {
|
|
272
|
+
// An inserted add-ons section (no authored slot) sits tight under the
|
|
273
|
+
// plans and carries its own headline, so a lone tile is never an
|
|
274
|
+
// unlabeled band (design review B2).
|
|
275
|
+
...(addonsSlot ? {} : { headline: addonsHeadline, gapAbove: "tight" }),
|
|
276
|
+
...authoredCopy(addonsSlot, ["addonLayout"]),
|
|
277
|
+
...pricing.addons,
|
|
278
|
+
} as PricingData,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const placed: PageSection[] = [];
|
|
282
|
+
for (const section of sections) {
|
|
283
|
+
if (!isPricing(section)) placed.push(section);
|
|
284
|
+
else if (section === plansSlot && plansSection) placed.push(plansSection);
|
|
285
|
+
else if (section === addonsSlot && addonsSection)
|
|
286
|
+
placed.push(addonsSection);
|
|
287
|
+
}
|
|
288
|
+
if (plansSection && !plansSlot) {
|
|
289
|
+
placed.splice(insertionIndex(placed), 0, plansSection);
|
|
290
|
+
}
|
|
291
|
+
if (addonsSection && !addonsSlot) {
|
|
292
|
+
const plansAt = plansSection ? placed.indexOf(plansSection) : -1;
|
|
293
|
+
placed.splice(
|
|
294
|
+
plansAt >= 0 ? plansAt + 1 : insertionIndex(placed),
|
|
295
|
+
0,
|
|
296
|
+
addonsSection,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return placed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The section the contact email is slotted into (D5). */
|
|
303
|
+
const CONTACT_SECTION_ID = "offerings-contact";
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The page's sections with every `pricing` section removed and one
|
|
307
|
+
* `contact-form` section in the first one's place, else after a leading hero
|
|
308
|
+
* (D5). It contributes no JSON-LD, so the page publishes no `Offer`.
|
|
309
|
+
*/
|
|
310
|
+
function withContactFallback(sections: PageSection[]): PageSection[] {
|
|
311
|
+
const first = sections.findIndex(isPricing);
|
|
312
|
+
const kept: PageSection[] = sections.filter((s) => !isPricing(s));
|
|
313
|
+
const contact = {
|
|
314
|
+
id: CONTACT_SECTION_ID,
|
|
315
|
+
type: "contact-form",
|
|
316
|
+
data: { fields: [] },
|
|
317
|
+
} as PageSection;
|
|
318
|
+
kept.splice(first >= 0 ? first : insertionIndex(kept), 0, contact);
|
|
319
|
+
return kept;
|
|
320
|
+
}
|
|
321
|
+
|
|
19
322
|
export async function generateMetadata({
|
|
20
323
|
params,
|
|
21
324
|
}: Pick<Props, "params">): Promise<Metadata> {
|
|
@@ -25,16 +328,74 @@ export async function generateMetadata({
|
|
|
25
328
|
|
|
26
329
|
export default async function Page({
|
|
27
330
|
params,
|
|
331
|
+
searchParams,
|
|
28
332
|
getMdxSource,
|
|
29
|
-
getPageSections,
|
|
333
|
+
getPageSections = defaultGetPageSections,
|
|
30
334
|
}: Props): Promise<ReactElement> {
|
|
31
335
|
const { locale } = await params;
|
|
336
|
+
const search = (await searchParams) ?? {};
|
|
337
|
+
const plan = Array.isArray(search.plan) ? search.plan[0] : search.plan;
|
|
338
|
+
const read = await readShownOfferings();
|
|
339
|
+
|
|
340
|
+
if (read.kind === "unconnected") {
|
|
341
|
+
return renderPage({
|
|
342
|
+
type: "Pricing",
|
|
343
|
+
slug: "pricing",
|
|
344
|
+
locale,
|
|
345
|
+
translationsNamespace: "pricing",
|
|
346
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
347
|
+
getPageSections: plan
|
|
348
|
+
? async (frontmatter) =>
|
|
349
|
+
withRecommendedPlan(await getPageSections(frontmatter), plan)
|
|
350
|
+
: getPageSections,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (read.kind === "shown") {
|
|
355
|
+
const { labels, recommendedLabel, addonsHeadline } =
|
|
356
|
+
await offeringsCopy(locale);
|
|
357
|
+
const pricing = offeringsPricing(read.shownSet, { locale, labels });
|
|
358
|
+
if (pricing && (pricing.plans || pricing.addons)) {
|
|
359
|
+
return renderPage({
|
|
360
|
+
type: "Pricing",
|
|
361
|
+
slug: "pricing",
|
|
362
|
+
locale,
|
|
363
|
+
translationsNamespace: "pricing",
|
|
364
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
365
|
+
getPageSections: async (frontmatter) =>
|
|
366
|
+
withOfferings(
|
|
367
|
+
await getPageSections(frontmatter),
|
|
368
|
+
pricing,
|
|
369
|
+
plan,
|
|
370
|
+
recommendedLabel,
|
|
371
|
+
addonsHeadline,
|
|
372
|
+
),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const text = contactFormText(
|
|
378
|
+
await getTranslations({ locale, namespace: "contact" }),
|
|
379
|
+
);
|
|
32
380
|
return renderPage({
|
|
33
381
|
type: "Pricing",
|
|
34
382
|
slug: "pricing",
|
|
35
383
|
locale,
|
|
36
384
|
translationsNamespace: "pricing",
|
|
37
385
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
38
|
-
|
|
386
|
+
getPageSections: async (frontmatter) =>
|
|
387
|
+
withContactFallback(await getPageSections(frontmatter)),
|
|
388
|
+
sectionSlots: {
|
|
389
|
+
[CONTACT_SECTION_ID]: (
|
|
390
|
+
<ContactFallback
|
|
391
|
+
email={readSiteContactEmail()}
|
|
392
|
+
text={{
|
|
393
|
+
heading: text.unavailableHeading,
|
|
394
|
+
body: text.unavailableBody,
|
|
395
|
+
noEmail: text.unavailableNoEmail,
|
|
396
|
+
}}
|
|
397
|
+
/>
|
|
398
|
+
),
|
|
399
|
+
},
|
|
39
400
|
});
|
|
40
401
|
}
|
|
@@ -68,7 +68,10 @@ describe("team/[slug] — the profile page", () => {
|
|
|
68
68
|
const person = profile["mainEntity"] as JsonLd;
|
|
69
69
|
expect(person["@type"]).toBe("Person");
|
|
70
70
|
expect(person["@id"]).toBe("#person");
|
|
71
|
-
|
|
71
|
+
// The seed's `jobTitle` is a `{placeholder}` slot, which never reaches the
|
|
72
|
+
// graph (#8797 D8.5).
|
|
73
|
+
expect(FIRST_MEMBER.frontmatter.schema.jobTitle).toBe("{Their title}");
|
|
74
|
+
expect(person).not.toHaveProperty("jobTitle");
|
|
72
75
|
|
|
73
76
|
const breadcrumb = graph.find((b) => b["@type"] === "BreadcrumbList")!;
|
|
74
77
|
const items = breadcrumb["itemListElement"] as Array<{ item: string }>;
|
package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx
CHANGED
|
@@ -139,6 +139,19 @@ for (const specifier of ["next/link", "next/image", "next/script"]) {
|
|
|
139
139
|
}
|
|
140
140
|
// `next-intl` is path-independent: jest.config.cjs maps every copy onto one stub.
|
|
141
141
|
jest.doMock("next-intl", () => flight.createClientModuleProxy("next-intl"));
|
|
142
|
+
// #8847 D12 — the page reads the newsletter magnet through
|
|
143
|
+
// `lib/in-page-magnet`, whose module graph reaches `contact.actions`' import
|
|
144
|
+
// of `next/navigation`. Next serves server code that entry's react-server
|
|
145
|
+
// build; under jest it is the client build, which needs a React context, so
|
|
146
|
+
// it is stubbed as `signed-in-nav-rsc-boundary.test.tsx` stubs it.
|
|
147
|
+
jest.doMock("next/navigation", () => ({
|
|
148
|
+
redirect: () => {
|
|
149
|
+
throw new Error("redirect");
|
|
150
|
+
},
|
|
151
|
+
notFound: () => {
|
|
152
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
153
|
+
},
|
|
154
|
+
}));
|
|
142
155
|
|
|
143
156
|
// ── Render ───────────────────────────────────────────────────────────────────
|
|
144
157
|
|
|
@@ -200,6 +213,10 @@ describe("/[locale]/unsubscribe crosses the RSC → client boundary cleanly", ()
|
|
|
200
213
|
const { errors, payload } = await renderPageToFlight("tok_1");
|
|
201
214
|
expect(errors).toEqual([]);
|
|
202
215
|
expect(payload).toContain("unsubscribe-page");
|
|
216
|
+
// #8847 D12 — the island gets the token and the magnet's copy as strings.
|
|
217
|
+
const props = /"token":"tok_1"[^}]*}/.exec(payload)?.[0] ?? "";
|
|
218
|
+
expect(props).toContain('"cta":"Unsubscribe"');
|
|
219
|
+
expect(props).toContain('"doneHeadline":"You\'re unsubscribed."');
|
|
203
220
|
});
|
|
204
221
|
|
|
205
222
|
it("serializes with no token", async () => {
|
|
@@ -40,9 +40,11 @@ jest.mock("next-intl", () => ({
|
|
|
40
40
|
}));
|
|
41
41
|
|
|
42
42
|
const mockUnsubscribe = jest.fn();
|
|
43
|
+
const mockFlowsGet = jest.fn();
|
|
43
44
|
jest.mock("@working-theory/api-client", () => ({
|
|
44
45
|
__esModule: true,
|
|
45
46
|
consent: { unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args) },
|
|
47
|
+
flows: { get: (...args: unknown[]) => mockFlowsGet(...args) },
|
|
46
48
|
visitorForwardHeaders: () => undefined,
|
|
47
49
|
}));
|
|
48
50
|
|
|
@@ -56,8 +58,16 @@ import Page, { generateMetadata } from "../page";
|
|
|
56
58
|
const ok = <T,>(body: T) => ({ ok: true, body });
|
|
57
59
|
const refusal = (status: number) => ({ ok: false, status, body: null });
|
|
58
60
|
|
|
61
|
+
// #8847 D12 — the page's newsletter-magnet read is shared for a few seconds
|
|
62
|
+
// per module instance; each test starts an hour later so none sees another's.
|
|
63
|
+
let clock = 1_900_000_000_000;
|
|
64
|
+
jest.spyOn(Date, "now").mockImplementation(() => clock);
|
|
65
|
+
|
|
59
66
|
beforeEach(() => {
|
|
67
|
+
clock += 60 * 60 * 1000;
|
|
60
68
|
mockUnsubscribe.mockReset();
|
|
69
|
+
// The hub unreadable: the seeded newsletterFlowConfig's unsubscribe copy.
|
|
70
|
+
mockFlowsGet.mockReset().mockRejectedValue(new Error("hub down"));
|
|
61
71
|
});
|
|
62
72
|
|
|
63
73
|
async function renderPage(token: string | undefined): Promise<void> {
|
|
@@ -86,7 +96,7 @@ describe("the page performs no write on GET", () => {
|
|
|
86
96
|
expect(mockUnsubscribe).not.toHaveBeenCalled();
|
|
87
97
|
expect(screen.getByTestId("unsubscribe-page")).toBeInTheDocument();
|
|
88
98
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Unsubscribe");
|
|
89
|
-
expect(screen.getByText("Stop
|
|
99
|
+
expect(screen.getByText("Stop getting the newsletter by email?")).toBeInTheDocument();
|
|
90
100
|
});
|
|
91
101
|
});
|
|
92
102
|
|
|
@@ -100,7 +110,7 @@ describe("with no token", () => {
|
|
|
100
110
|
});
|
|
101
111
|
|
|
102
112
|
describe("clicking Unsubscribe", () => {
|
|
103
|
-
it("a changed answer shows the done
|
|
113
|
+
it("a changed answer shows the magnet's done message (#8847 D12)", async () => {
|
|
104
114
|
mockUnsubscribe.mockResolvedValue(
|
|
105
115
|
ok({ purpose: { id: "marketing_email", description: "product news and offers" }, changed: true }),
|
|
106
116
|
);
|
|
@@ -108,7 +118,7 @@ describe("clicking Unsubscribe", () => {
|
|
|
108
118
|
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
109
119
|
expect(mockUnsubscribe).toHaveBeenCalledWith({ token: "tok_1" });
|
|
110
120
|
expect(await screen.findByTestId("unsubscribe-done")).toHaveTextContent(
|
|
111
|
-
"You're unsubscribed
|
|
121
|
+
"You're unsubscribed.You won't get the newsletter any more.",
|
|
112
122
|
);
|
|
113
123
|
});
|
|
114
124
|
|
|
@@ -156,3 +166,52 @@ describe("clicking Unsubscribe", () => {
|
|
|
156
166
|
expect(await screen.findByTestId("unsubscribe-unavailable")).toBeInTheDocument();
|
|
157
167
|
});
|
|
158
168
|
});
|
|
169
|
+
|
|
170
|
+
describe("the copy comes from the newsletter magnet (#8847 D12, AC-7)", () => {
|
|
171
|
+
const unsubscribe = {
|
|
172
|
+
title: { en: "Leave" },
|
|
173
|
+
body: { en: "Sure?" },
|
|
174
|
+
cta: { en: "Leave now" },
|
|
175
|
+
done: { headline: { en: "Gone." } },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
const { newsletterFlowConfig } = jest.requireActual("@working-theory/validation") as {
|
|
180
|
+
newsletterFlowConfig: Record<string, unknown>;
|
|
181
|
+
};
|
|
182
|
+
const { completionEmail: _omitted, ...body } = newsletterFlowConfig;
|
|
183
|
+
mockFlowsGet.mockReset().mockResolvedValue({ ok: true, status: 200, body: { ...body, unsubscribe } });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("the title, line and button are the served block's, and so is the done message", async () => {
|
|
187
|
+
mockUnsubscribe.mockResolvedValue(
|
|
188
|
+
ok({ purpose: { id: "newsletter", description: "This site's newsletter by email" }, changed: true }),
|
|
189
|
+
);
|
|
190
|
+
await renderPage("tok_1");
|
|
191
|
+
expect(mockFlowsGet).toHaveBeenCalledWith("newsletter");
|
|
192
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Leave");
|
|
193
|
+
expect(screen.getByText("Sure?")).toBeInTheDocument();
|
|
194
|
+
expect(screen.getByTestId("unsubscribe-button")).toHaveTextContent("Leave now");
|
|
195
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
196
|
+
expect(await screen.findByTestId("unsubscribe-done")).toHaveTextContent("Gone.");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("generateMetadata's title is the served block's", async () => {
|
|
200
|
+
const meta = await generateMetadata({
|
|
201
|
+
params: Promise.resolve({ locale: "en" }),
|
|
202
|
+
searchParams: Promise.resolve({}),
|
|
203
|
+
});
|
|
204
|
+
expect(meta.title).toBe("Leave");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("already still shows the catalog's line", async () => {
|
|
208
|
+
mockUnsubscribe.mockResolvedValue(
|
|
209
|
+
ok({ purpose: { id: "newsletter", description: "This site's newsletter by email" }, changed: false }),
|
|
210
|
+
);
|
|
211
|
+
await renderPage("tok_1");
|
|
212
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
213
|
+
expect(await screen.findByTestId("unsubscribe-already")).toHaveTextContent(
|
|
214
|
+
"You were already unsubscribed.",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -4,32 +4,73 @@
|
|
|
4
4
|
* `?t=` token never leaks through a Referer header off this page (D17,
|
|
5
5
|
* SEC-8438-9).
|
|
6
6
|
*
|
|
7
|
-
* RSC: reads `?t=` and renders the heading, the
|
|
7
|
+
* RSC: reads `?t=` and renders the heading, the line and the one
|
|
8
8
|
* interactive piece (`UnsubscribeButton`, "use client") that posts
|
|
9
9
|
* `unsubscribeAction(token)` ON CLICK — never on this GET, so a mail
|
|
10
|
-
* scanner's prefetch writes nothing. The island gets the token and
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
10
|
+
* scanner's prefetch writes nothing. The island gets the token and three
|
|
11
|
+
* strings: anything callable handed across this server→client edge fails
|
|
12
|
+
* Flight serialization (the scaffold matrix's `GET /unsubscribe -> 500`;
|
|
13
|
+
* `__tests__/unsubscribe-rsc-boundary.test.tsx`).
|
|
14
|
+
*
|
|
15
|
+
* #8847 D12 — the title, line, button and done message come from the
|
|
16
|
+
* newsletter magnet's `unsubscribe` block (`readServedMagnet`, shared like
|
|
17
|
+
* /contact's read), else the seeded `newsletterFlowConfig`'s. The token states
|
|
18
|
+
* (already, invalid, unavailable) keep their catalog lines: they describe
|
|
19
|
+
* the link, not the newsletter. The frame is /contact's `column` look. The
|
|
20
|
+
* page renders on every request.
|
|
14
21
|
*/
|
|
15
22
|
|
|
23
|
+
import { SectionFrame } from "@working-theory/ui";
|
|
16
24
|
import { Heading, Text } from "@working-theory/ui/primitives";
|
|
25
|
+
import {
|
|
26
|
+
NEWSLETTER_FLOW_SLUG,
|
|
27
|
+
newsletterFlowConfig,
|
|
28
|
+
resolveLocalizedString,
|
|
29
|
+
type FlowUnsubscribeCopy,
|
|
30
|
+
} from "@working-theory/validation";
|
|
17
31
|
import type { Metadata } from "next";
|
|
18
|
-
import { getTranslations } from "next-intl/server";
|
|
19
32
|
import type { ReactElement } from "react";
|
|
20
33
|
|
|
34
|
+
import { readServedMagnet } from "../../../lib/in-page-magnet";
|
|
35
|
+
|
|
21
36
|
import { UnsubscribeButton } from "./unsubscribe-button";
|
|
22
37
|
|
|
38
|
+
export const dynamic = "force-dynamic";
|
|
39
|
+
|
|
23
40
|
interface Props {
|
|
24
41
|
params: Promise<{ locale: string }>;
|
|
25
42
|
searchParams: Promise<{ t?: string }>;
|
|
26
43
|
}
|
|
27
44
|
|
|
45
|
+
interface UnsubscribeCopy {
|
|
46
|
+
title: string;
|
|
47
|
+
body?: string;
|
|
48
|
+
cta: string;
|
|
49
|
+
doneHeadline: string;
|
|
50
|
+
doneBody?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The newsletter magnet's unsubscribe copy, else the seeded config's. */
|
|
54
|
+
async function unsubscribeCopy(locale: string): Promise<UnsubscribeCopy> {
|
|
55
|
+
const served = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
|
|
56
|
+
const config = served?.unsubscribe ? served : newsletterFlowConfig;
|
|
57
|
+
const block = config.unsubscribe as FlowUnsubscribeCopy;
|
|
58
|
+
const resolve = (value: Record<string, string>): string =>
|
|
59
|
+
resolveLocalizedString(value, locale, config.defaultLocale);
|
|
60
|
+
return {
|
|
61
|
+
title: resolve(block.title),
|
|
62
|
+
...(block.body !== undefined && { body: resolve(block.body) }),
|
|
63
|
+
cta: resolve(block.cta),
|
|
64
|
+
doneHeadline: resolve(block.done.headline),
|
|
65
|
+
...(block.done.body !== undefined && { doneBody: resolve(block.done.body) }),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
29
70
|
const { locale } = await params;
|
|
30
|
-
const
|
|
71
|
+
const copy = await unsubscribeCopy(locale);
|
|
31
72
|
return {
|
|
32
|
-
title:
|
|
73
|
+
title: copy.title,
|
|
33
74
|
robots: { index: false, follow: true },
|
|
34
75
|
referrer: "no-referrer",
|
|
35
76
|
};
|
|
@@ -38,17 +79,28 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
38
79
|
export default async function UnsubscribePage({ params, searchParams }: Props): Promise<ReactElement> {
|
|
39
80
|
const { locale } = await params;
|
|
40
81
|
const { t: token } = await searchParams;
|
|
41
|
-
const
|
|
82
|
+
const copy = await unsubscribeCopy(locale);
|
|
42
83
|
|
|
43
84
|
return (
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
<SectionFrame sectionId="unsubscribe" className="ailk-contact-frame" data-testid="unsubscribe-page">
|
|
86
|
+
<div className="ailk-contact-column text-center">
|
|
87
|
+
<Heading level={1} visual="display" className="ailk-contact-heading text-center">
|
|
88
|
+
{copy.title}
|
|
89
|
+
</Heading>
|
|
90
|
+
{copy.body !== undefined && (
|
|
91
|
+
<Text as="p" tone="muted" className="mx-auto mt-block-tight ailk-contact-measure text-balance">
|
|
92
|
+
{copy.body}
|
|
93
|
+
</Text>
|
|
94
|
+
)}
|
|
95
|
+
<div className="ailk-contact-slot mx-auto mt-block-loose flex w-full ailk-contact-measure flex-col items-center">
|
|
96
|
+
<UnsubscribeButton
|
|
97
|
+
token={token ?? null}
|
|
98
|
+
cta={copy.cta}
|
|
99
|
+
doneHeadline={copy.doneHeadline}
|
|
100
|
+
{...(copy.doneBody !== undefined && { doneBody: copy.doneBody })}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</SectionFrame>
|
|
53
105
|
);
|
|
54
106
|
}
|