create-ailk 0.26.0 → 0.28.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 +86 -9
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +25 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +112 -11
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +90 -6
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +161 -128
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +44 -4
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// nav-label-keys.ts — the set of `nav` message keys the site chrome resolves
|
|
2
|
+
// (#10045).
|
|
3
|
+
//
|
|
4
|
+
// The header and footer render translated strings, but `nav-config.ts`
|
|
5
|
+
// carries only message keys. Every key is looked up in the `nav` namespace by
|
|
6
|
+
// name — there is no hand-written list of allowed keys — so adding a page to
|
|
7
|
+
// the nav is one entry in `nav-config.ts` plus its text in
|
|
8
|
+
// `messages/<locale>.json` (or the site overlay, `messages/site/<locale>.json`).
|
|
9
|
+
// `scripts/check-nav-labels.ts` fails the build when a key used here is
|
|
10
|
+
// missing from a locale's catalog.
|
|
11
|
+
|
|
12
|
+
import { footerColumns, navItems } from "./nav-config";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every field on a nav-config entry that names a `nav` message key:
|
|
16
|
+
* `labelKey` (an item or action), `headingKey` (a flyout group or footer
|
|
17
|
+
* column) and `descKey` (a flyout feature's description). These are the only
|
|
18
|
+
* three key-bearing fields in `@working-theory/ui`'s chrome types.
|
|
19
|
+
*/
|
|
20
|
+
const KEY_FIELDS: ReadonlySet<string> = new Set([
|
|
21
|
+
"labelKey",
|
|
22
|
+
"headingKey",
|
|
23
|
+
"descKey",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `nav` keys the layout itself resolves by name, beside the ones
|
|
28
|
+
* `nav-config.ts` declares: the session actions, the centred footer's FAQ
|
|
29
|
+
* link and the menu controls.
|
|
30
|
+
*/
|
|
31
|
+
export const LAYOUT_NAV_KEYS: readonly string[] = [
|
|
32
|
+
"account",
|
|
33
|
+
"signIn",
|
|
34
|
+
"signOut",
|
|
35
|
+
"signUp",
|
|
36
|
+
"faq",
|
|
37
|
+
"openMenu",
|
|
38
|
+
"closeMenu",
|
|
39
|
+
"moreLinks",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** Every key-bearing field value in the given nav configs, at any depth. */
|
|
43
|
+
export function collectNavLabelKeys(
|
|
44
|
+
...configs: readonly unknown[]
|
|
45
|
+
): Set<string> {
|
|
46
|
+
const keys = new Set<string>();
|
|
47
|
+
const walk = (value: unknown): void => {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
for (const entry of value) walk(entry);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value !== "object" || value === null) return;
|
|
53
|
+
for (const [field, child] of Object.entries(value)) {
|
|
54
|
+
if (KEY_FIELDS.has(field)) {
|
|
55
|
+
if (typeof child === "string") keys.add(child);
|
|
56
|
+
} else {
|
|
57
|
+
walk(child);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
for (const config of configs) walk(config);
|
|
62
|
+
return keys;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The string keys of a message catalog's `nav` namespace. */
|
|
66
|
+
export function navMessageKeys(catalog: unknown): string[] {
|
|
67
|
+
if (typeof catalog !== "object" || catalog === null) return [];
|
|
68
|
+
const nav = (catalog as Record<string, unknown>)["nav"];
|
|
69
|
+
if (typeof nav !== "object" || nav === null) return [];
|
|
70
|
+
return Object.entries(nav)
|
|
71
|
+
.filter(([, value]) => typeof value === "string")
|
|
72
|
+
.map(([key]) => key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every `nav` key the site chrome uses: `nav-config.ts`'s keys and the layout's own. */
|
|
76
|
+
export function usedNavLabelKeys(): Set<string> {
|
|
77
|
+
return new Set([...collectNavLabelKeys(navItems, footerColumns), ...LAYOUT_NAV_KEYS]);
|
|
78
|
+
}
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
* is success; a 503 `lead_capture_not_configured` or an unreachable API
|
|
33
33
|
* (status 0, 502, 504) is `unavailable`. Otherwise (a Free build has no flow
|
|
34
34
|
* route) it posts `leads.subscribe`, as before.
|
|
35
|
+
*
|
|
36
|
+
* The bot check (#9255 D4): each write path runs `runBotCheck` after its
|
|
37
|
+
* consent refusal and immediately before its hub call, so one signup is
|
|
38
|
+
* checked exactly once. A bot gets the generic error and a check that cannot
|
|
39
|
+
* decide `unavailable`; neither is sent.
|
|
35
40
|
*/
|
|
36
41
|
|
|
37
42
|
import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
@@ -41,8 +46,9 @@ import { cookies, headers } from "next/headers";
|
|
|
41
46
|
import { getLocale } from "next-intl/server";
|
|
42
47
|
import { z } from "zod";
|
|
43
48
|
|
|
44
|
-
import {
|
|
49
|
+
import { clientIpFromHeaders, takeVisitorLimit } from "../src/lib/rate-limit";
|
|
45
50
|
|
|
51
|
+
import { runBotCheck } from "./bot-check";
|
|
46
52
|
import { consentVisitorHeader, readConsentVisitor } from "./consent-visitor";
|
|
47
53
|
import { inPageView, readServedMagnet } from "./in-page-magnet";
|
|
48
54
|
|
|
@@ -54,6 +60,8 @@ const RATE_LIMITED = "Too many signups from here. Please try again in a few minu
|
|
|
54
60
|
// spend the bucket every visitor shares.
|
|
55
61
|
const RATE_LIMIT_MAX_CALLS = 30;
|
|
56
62
|
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
63
|
+
/** Outside a request scope there is no address: the shared bucket. */
|
|
64
|
+
const NO_HEADERS = { get: () => null };
|
|
57
65
|
|
|
58
66
|
/** The form's one field; zod's default message is the field error shown. */
|
|
59
67
|
const newsletterFormSchema = z.object({ email: z.string().email() });
|
|
@@ -73,6 +81,18 @@ function isApiUnreachable(res: { status: number }): boolean {
|
|
|
73
81
|
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
/** #9255 D4 — the spoke edge's bot check; `null` to go on to the write. */
|
|
85
|
+
async function botRefusal(): Promise<LeadCaptureFormState | null> {
|
|
86
|
+
const botCheck = await runBotCheck({
|
|
87
|
+
source: "bot-check",
|
|
88
|
+
target: "subscribeNewsletterAction",
|
|
89
|
+
scope: "write",
|
|
90
|
+
});
|
|
91
|
+
if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
|
|
92
|
+
if (botCheck === "unavailable") return { kind: "unavailable" };
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
76
96
|
/**
|
|
77
97
|
* The signup through the served newsletter magnet, or `null` when it does not
|
|
78
98
|
* map (absent, draft, a Free build with no flow route, or not a one-field
|
|
@@ -102,6 +122,8 @@ async function submitThroughMagnet(
|
|
|
102
122
|
for (const purpose of consent.purposes) values[`${consent.field}.${purpose}`] = true;
|
|
103
123
|
}
|
|
104
124
|
const payload = { stepKey: view.stepKey, values, locale };
|
|
125
|
+
const refused = await botRefusal();
|
|
126
|
+
if (refused !== null) return refused;
|
|
105
127
|
try {
|
|
106
128
|
const res = init
|
|
107
129
|
? await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload, init)
|
|
@@ -142,15 +164,15 @@ export async function subscribeNewsletterAction(
|
|
|
142
164
|
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
143
165
|
}
|
|
144
166
|
|
|
145
|
-
// No readable address:
|
|
146
|
-
//
|
|
147
|
-
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
167
|
+
// No readable address: the shared, wider bucket, never a skip (#9967 D1,
|
|
168
|
+
// the contact action's rule).
|
|
148
169
|
if (
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
!takeVisitorLimit("newsletter", requestHeaders ?? NO_HEADERS, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
171
|
+
.allowed
|
|
151
172
|
) {
|
|
152
173
|
return { kind: "error", message: RATE_LIMITED };
|
|
153
174
|
}
|
|
175
|
+
const visitor = clientIpFromHeaders(requestHeaders ?? NO_HEADERS);
|
|
154
176
|
|
|
155
177
|
let init: { headers: Record<string, string> } | undefined;
|
|
156
178
|
if (requestHeaders) {
|
|
@@ -179,6 +201,9 @@ export async function subscribeNewsletterAction(
|
|
|
179
201
|
// The Free path renders the seeded config, whose consent box gates the post.
|
|
180
202
|
if (!consented) return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
181
203
|
|
|
204
|
+
const refused = await botRefusal();
|
|
205
|
+
if (refused !== null) return refused;
|
|
206
|
+
|
|
182
207
|
const siteId = process.env.AILK_SITE_ID ?? "default";
|
|
183
208
|
const body = { siteId, source: "newsletter", email: parsed.data.email };
|
|
184
209
|
try {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* - T2 — every Stripe string is emitted as a plain text field, with format
|
|
14
14
|
* characters removed; nothing is parsed as HTML or Markdown (D8).
|
|
15
15
|
* - T3 — every link is `OFFERINGS_CHECKOUT_PATH` plus a lookup key that
|
|
16
|
-
* `parseLookupKey` accepts
|
|
16
|
+
* `parseLookupKey` accepts — plus, for a bundled plan, `?with=` and the
|
|
17
|
+
* required product's key (#10028 D9); no href is built from any other
|
|
18
|
+
* field (D5).
|
|
17
19
|
* - §3.7 — `yearly` reads as annual and `once` as one-time at render, never
|
|
18
20
|
* in the key (D2).
|
|
19
21
|
*
|
|
@@ -49,6 +51,10 @@ export interface OfferingsPricingLabels {
|
|
|
49
51
|
monthly(amount: string): string;
|
|
50
52
|
annual(amount: string): string;
|
|
51
53
|
oneTime(amount: string): string;
|
|
54
|
+
/** A bundled plan's phrase: its one-time phrase plus its required product's recurring phrase (#10028 D9). */
|
|
55
|
+
withRequired(plan: string, required: string): string;
|
|
56
|
+
/** A deposit plan's footnote, from the formatted deposit (#10030 D10). */
|
|
57
|
+
depositNote(deposit: string): string;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export interface OfferingsPricing {
|
|
@@ -69,6 +75,21 @@ export function offeringsCheckoutHref(lookupKey: string): string | null {
|
|
|
69
75
|
: null;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The CTA target for a bundled plan (#10028 D9): the plan's key plus the
|
|
80
|
+
* required product's key as `?with=`, or `null` when either is outside the
|
|
81
|
+
* grammar. Both keys are `[a-z0-9_-]` only, so neither needs encoding.
|
|
82
|
+
*/
|
|
83
|
+
export function offeringsBundleHref(
|
|
84
|
+
planKey: string,
|
|
85
|
+
requiredKey: string,
|
|
86
|
+
): string | null {
|
|
87
|
+
const base = offeringsCheckoutHref(planKey);
|
|
88
|
+
return base && parseLookupKey(requiredKey)
|
|
89
|
+
? `${base}?with=${requiredKey}`
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
/**
|
|
73
94
|
* Whether `lookupKey` is a grammar key of a Price in a current, non-empty
|
|
74
95
|
* shown set — the keys `/pricing`'s cards are built from (#9051 D4).
|
|
@@ -194,6 +215,63 @@ function firstRecommended(products: OfferingsProduct[]): string | undefined {
|
|
|
194
215
|
return products.find((p) => p.recommended)?.plan;
|
|
195
216
|
}
|
|
196
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A bundled plan's card (#10028 D9): the plan's one-time Price with its
|
|
220
|
+
* required product's recurring Prices — one link per shown recurring
|
|
221
|
+
* interval. With monthly and yearly, the monthly link is the CTA and the
|
|
222
|
+
* yearly one the annual link, each mode phrasing both amounts; with one,
|
|
223
|
+
* that link alone and the phrase as the custom price. No card when the plan's
|
|
224
|
+
* own one-time Price, or a recurring Price of the required product, is not
|
|
225
|
+
* shown.
|
|
226
|
+
*/
|
|
227
|
+
function bundleCard(
|
|
228
|
+
product: OfferingsProduct,
|
|
229
|
+
required: OfferingsProduct,
|
|
230
|
+
highlighted: boolean,
|
|
231
|
+
locale: string,
|
|
232
|
+
labels: OfferingsPricingLabels,
|
|
233
|
+
): PricingPlanCard | null {
|
|
234
|
+
const once = priceFor(product, PERIOD.once);
|
|
235
|
+
const monthly = priceFor(required, PERIOD.monthly);
|
|
236
|
+
const yearly = priceFor(required, PERIOD.yearly);
|
|
237
|
+
const first = monthly ?? yearly;
|
|
238
|
+
if (!once || !first) return null;
|
|
239
|
+
const href = offeringsBundleHref(once.lookupKey, first.lookupKey);
|
|
240
|
+
if (!href) return null;
|
|
241
|
+
const oneTime = phrase(once, locale, labels);
|
|
242
|
+
const combined = (recurring: OfferingsPrice) =>
|
|
243
|
+
labels.withRequired(oneTime, phrase(recurring, locale, labels));
|
|
244
|
+
const price: PricingPlanCard["price"] = {};
|
|
245
|
+
if (monthly && yearly) {
|
|
246
|
+
price.monthly = combined(monthly);
|
|
247
|
+
price.annual = combined(yearly);
|
|
248
|
+
} else {
|
|
249
|
+
price.custom = combined(first);
|
|
250
|
+
}
|
|
251
|
+
const card: PricingPlanCard = {
|
|
252
|
+
key: product.plan,
|
|
253
|
+
name: stripFormatCharacters(product.name),
|
|
254
|
+
price,
|
|
255
|
+
features: features(product),
|
|
256
|
+
cta: { label: labels.cta, href },
|
|
257
|
+
};
|
|
258
|
+
// The deposit note wins the footnote; the terms note shows only without one.
|
|
259
|
+
// NO_HAND_IMPLEMENT_OK: builder-assessors run for #10030 — the builder is the implementer.
|
|
260
|
+
if (product.deposit !== undefined)
|
|
261
|
+
card.priceFootnote = labels.depositNote(formatMinorUnits(product.deposit, once.currency, locale));
|
|
262
|
+
else if (product.termsNote)
|
|
263
|
+
card.priceFootnote = stripFormatCharacters(product.termsNote);
|
|
264
|
+
const annualHref =
|
|
265
|
+
monthly && yearly && offeringsBundleHref(once.lookupKey, yearly.lookupKey);
|
|
266
|
+
if (annualHref) card.annualCtaHref = annualHref;
|
|
267
|
+
if (highlighted) {
|
|
268
|
+
card.highlighted = true;
|
|
269
|
+
card.highlightStyle = "pill";
|
|
270
|
+
card.highlightLabel = labels.recommended;
|
|
271
|
+
}
|
|
272
|
+
return card;
|
|
273
|
+
}
|
|
274
|
+
|
|
197
275
|
function planCard(
|
|
198
276
|
product: OfferingsProduct,
|
|
199
277
|
highlighted: boolean,
|
|
@@ -227,6 +305,10 @@ function planCard(
|
|
|
227
305
|
if (product.note) card.priceSubhead = stripFormatCharacters(product.note);
|
|
228
306
|
if (product.termsNote)
|
|
229
307
|
card.priceFootnote = stripFormatCharacters(product.termsNote);
|
|
308
|
+
// A deposit plan's footnote (#10030 D10), in its one-time Price's currency;
|
|
309
|
+
// it takes the footnote over a terms note (the rarer field).
|
|
310
|
+
if (product.deposit !== undefined && once)
|
|
311
|
+
card.priceFootnote = labels.depositNote(formatMinorUnits(product.deposit, once.currency, locale));
|
|
230
312
|
// Annual mode shows the yearly Price only when a monthly one is beside it,
|
|
231
313
|
// so only then does the card need a second link (D5, D6).
|
|
232
314
|
const annualHref =
|
|
@@ -280,28 +362,75 @@ function isPresent<T>(value: T | null): value is T {
|
|
|
280
362
|
return value !== null;
|
|
281
363
|
}
|
|
282
364
|
|
|
365
|
+
/**
|
|
366
|
+
* The products whose `{plan}` is in `plans`, plus every product a kept one
|
|
367
|
+
* `requires`, in the snapshot's order (#10044 D4); `plans` absent → all.
|
|
368
|
+
* One level of `requires` is followed: a required product is sold only
|
|
369
|
+
* inside its bundle and requires nothing itself (#10028 decision 6).
|
|
370
|
+
*/
|
|
371
|
+
function narrowToPlans(
|
|
372
|
+
offerings: OfferingsProduct[],
|
|
373
|
+
plans: readonly string[] | undefined,
|
|
374
|
+
): OfferingsProduct[] {
|
|
375
|
+
if (plans === undefined) return offerings;
|
|
376
|
+
const listed = new Set(plans);
|
|
377
|
+
const kept = new Set(
|
|
378
|
+
offerings.filter((o) => listed.has(o.plan)).map((o) => o.plan),
|
|
379
|
+
);
|
|
380
|
+
for (const o of offerings) {
|
|
381
|
+
if (kept.has(o.plan) && o.requires !== undefined) kept.add(o.requires);
|
|
382
|
+
}
|
|
383
|
+
return offerings.filter((o) => kept.has(o.plan));
|
|
384
|
+
}
|
|
385
|
+
|
|
283
386
|
/**
|
|
284
387
|
* The shown set as Pricing section data, or `null` when there is nothing to
|
|
285
388
|
* show: a value the shown-set schema refuses, a snapshot that is not
|
|
286
389
|
* `current`, or no offerings. Each fragment is `null` when its group is empty.
|
|
390
|
+
*
|
|
391
|
+
* `plans` (#10044 D4) narrows the set to an offering page's own products:
|
|
392
|
+
* those whose `{plan}` it lists, plus every product a kept one `requires`, in
|
|
393
|
+
* the snapshot's order. Absent, every shown product is mapped, as before.
|
|
287
394
|
*/
|
|
288
395
|
export function offeringsPricing(
|
|
289
396
|
shownSet: unknown,
|
|
290
|
-
options: {
|
|
397
|
+
options: {
|
|
398
|
+
locale: string;
|
|
399
|
+
labels: OfferingsPricingLabels;
|
|
400
|
+
plans?: readonly string[] | undefined;
|
|
401
|
+
},
|
|
291
402
|
): OfferingsPricing | null {
|
|
292
403
|
const parsed = shownSetSchema.safeParse(shownSet);
|
|
293
404
|
if (!parsed.success) return null;
|
|
294
|
-
const { state, offerings } = parsed.data;
|
|
295
|
-
if (state !== "current" ||
|
|
405
|
+
const { state, offerings: shown } = parsed.data;
|
|
406
|
+
if (state !== "current" || shown.length === 0) return null;
|
|
296
407
|
const { locale, labels } = options;
|
|
297
408
|
|
|
298
|
-
|
|
409
|
+
// #10028 D9 — a product some shown plan requires is sold only inside that
|
|
410
|
+
// bundle (decision 6), so it gets no card of its own. Read over the whole
|
|
411
|
+
// shown set, so a page listing only that product still shows it no card.
|
|
412
|
+
const requiredPlans = new Set(
|
|
413
|
+
shown.map((o) => o.requires).filter((plan) => plan !== undefined),
|
|
414
|
+
);
|
|
415
|
+
const offerings = narrowToPlans(shown, options.plans);
|
|
416
|
+
const planProducts = offerings.filter(
|
|
417
|
+
(o) => o.ailkGroup === "plan" && !requiredPlans.has(o.plan),
|
|
418
|
+
);
|
|
299
419
|
const addonProducts = offerings.filter((o) => o.ailkGroup === "addon");
|
|
300
420
|
const recommendedPlan = firstRecommended(planProducts);
|
|
301
421
|
const recommendedAddon = firstRecommended(addonProducts);
|
|
302
422
|
|
|
303
423
|
const plans = planProducts
|
|
304
|
-
.map((p) =>
|
|
424
|
+
.map((p) => {
|
|
425
|
+
const required =
|
|
426
|
+
p.requires === undefined
|
|
427
|
+
? undefined
|
|
428
|
+
: offerings.find((o) => o.plan === p.requires);
|
|
429
|
+
if (p.requires !== undefined) {
|
|
430
|
+
return required ? bundleCard(p, required, p.plan === recommendedPlan, locale, labels) : null;
|
|
431
|
+
}
|
|
432
|
+
return planCard(p, p.plan === recommendedPlan, locale, labels);
|
|
433
|
+
})
|
|
305
434
|
.filter(isPresent);
|
|
306
435
|
const addons = addonProducts
|
|
307
436
|
.map((p) => addonTile(p, p.plan === recommendedAddon, locale, labels))
|