create-ailk 0.21.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/component-catalog.json +37 -3
- package/component-catalog.md +6 -5
- package/dist/add-module.js +9 -5
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-package.js +7 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +19 -5
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/surfaces.js +19 -14
- package/dist/sync-routes.js +3 -2
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.js +5 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/templates/apps/api/CLAUDE.md +15 -6
- 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/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/public-site-resolver.ts +8 -1
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +120 -9
- 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]/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 +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
- 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]/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/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/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 +5 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
- 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 +6 -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 +24 -7
- 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 +38 -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 +34 -1
- package/templates/database/inbox/schema.prisma +17 -2
- 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 +34 -3
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The standard spoke checkout (#8748 D6; spec
|
|
7
|
+
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-3, AC-4).
|
|
8
|
+
*
|
|
9
|
+
* `./checkout-config` — the site's own data — is mocked with a fixture in
|
|
10
|
+
* every case, so this suite passes against any shape of the site's file,
|
|
11
|
+
* including a pre-#8748 one that also carries `siteBaseUrl` and `fulfil`.
|
|
12
|
+
* The hub's `leads.submit` is the one external boundary mocked: the
|
|
13
|
+
* fulfilment must post one lead marked with the site, under the session id as
|
|
14
|
+
* its Idempotency-Key, with a body that stays byte-identical between attempts
|
|
15
|
+
* so the hub can replay it.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
const mockSubmit = jest.fn();
|
|
21
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
leads: { submit: (...args: unknown[]) => mockSubmit(...args) },
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const OFFERINGS = {
|
|
27
|
+
starter: { kind: "fixed", lookupKey: "acme_starter", cancelPath: "/" },
|
|
28
|
+
};
|
|
29
|
+
const mockSite: { value: Record<string, unknown> } = {
|
|
30
|
+
value: { site: "acme", offerings: OFFERINGS },
|
|
31
|
+
};
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the site's checkout-config.ts is the site's own data, not this suite's subject; a fixture stands in for it so the suite holds against any shape a site's file has (#8748 AC-2).
|
|
33
|
+
jest.mock("../checkout-config", () => ({
|
|
34
|
+
__esModule: true,
|
|
35
|
+
get checkoutConfig() {
|
|
36
|
+
return mockSite.value;
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
41
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
42
|
+
jest.mock("../topology.generated", () => ({
|
|
43
|
+
__esModule: true,
|
|
44
|
+
get TOPOLOGY() {
|
|
45
|
+
return mockTopology.value;
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
type CheckoutModule = typeof import("../checkout");
|
|
50
|
+
|
|
51
|
+
/** `lib/checkout.ts`, loaded fresh against the current site fixture. */
|
|
52
|
+
function load(site: Record<string, unknown> = { site: "acme", offerings: OFFERINGS }): CheckoutModule {
|
|
53
|
+
mockSite.value = site;
|
|
54
|
+
let loaded!: CheckoutModule;
|
|
55
|
+
jest.isolateModules(() => {
|
|
56
|
+
loaded = require("../checkout") as CheckoutModule;
|
|
57
|
+
});
|
|
58
|
+
return loaded;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A test-mode completion as the package hands it to `fulfil`. */
|
|
62
|
+
const COMPLETION = {
|
|
63
|
+
idempotencyKey: "cs_test_1",
|
|
64
|
+
sessionId: "cs_test_1",
|
|
65
|
+
offering: "starter",
|
|
66
|
+
kind: "fixed" as const,
|
|
67
|
+
mode: "test" as const,
|
|
68
|
+
livemode: false,
|
|
69
|
+
paymentStatus: "paid" as const,
|
|
70
|
+
amountTotal: 4900,
|
|
71
|
+
currency: "usd",
|
|
72
|
+
email: "b@example.com",
|
|
73
|
+
customerId: "cus_test_1",
|
|
74
|
+
termsOfService: null,
|
|
75
|
+
termsStamp: null,
|
|
76
|
+
metadata: { site: "acme", offering: "starter", kind: "fixed" },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `renderConfig`'s body for COMPLETION, key order included (NFR5), plus the
|
|
81
|
+
* session's terms consent as its last key (#8778).
|
|
82
|
+
*/
|
|
83
|
+
const EXPECTED_BODY =
|
|
84
|
+
'{"siteId":"acme","source":"web","email":"b@example.com","payload":{"site":"acme","page":"checkout","offering":"starter","checkoutKind":"fixed","mode":"test","amountTotal":4900,"currency":"usd","stripeCustomerId":"cus_test_1","checkoutSession":"cs_test_1","termsAccepted":null}}';
|
|
85
|
+
|
|
86
|
+
const SOURCE = readFileSync(join(__dirname, "..", "checkout.ts"), "utf8");
|
|
87
|
+
const ENV = process.env;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
mockSubmit.mockReset();
|
|
91
|
+
mockSubmit.mockResolvedValue({ ok: true, status: 201 });
|
|
92
|
+
process.env = { ...ENV, AILK_SPOKE_KEY: "spoke-key-fixture" };
|
|
93
|
+
mockTopology.value = "spoke";
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(() => {
|
|
97
|
+
process.env = ENV;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("spokeCheckoutConfig (#8748 AC-2, AC-4)", () => {
|
|
101
|
+
it("exports exactly spokeCheckoutConfig, checkoutServed and checkoutNotServed", () => {
|
|
102
|
+
expect(Object.keys(load()).sort()).toEqual([
|
|
103
|
+
"checkoutNotServed",
|
|
104
|
+
"checkoutServed",
|
|
105
|
+
"spokeCheckoutConfig",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("has exactly site, offerings, siteBaseUrl and fulfil, the offerings being the site's own object", () => {
|
|
110
|
+
const { spokeCheckoutConfig } = load();
|
|
111
|
+
expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
|
|
112
|
+
"fulfil",
|
|
113
|
+
"offerings",
|
|
114
|
+
"site",
|
|
115
|
+
"siteBaseUrl",
|
|
116
|
+
]);
|
|
117
|
+
expect(spokeCheckoutConfig.site).toBe("acme");
|
|
118
|
+
expect(spokeCheckoutConfig.offerings).toBe(OFFERINGS);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("takes only site and offerings from a site file that also carries env, siteBaseUrl and fulfil (SEC-8748-B)", async () => {
|
|
122
|
+
const siteFulfil = jest.fn();
|
|
123
|
+
const { spokeCheckoutConfig } = load({
|
|
124
|
+
site: "acme",
|
|
125
|
+
offerings: OFFERINGS,
|
|
126
|
+
env: { STRIPE_MODE: "live" },
|
|
127
|
+
siteBaseUrl: "https://elsewhere.example",
|
|
128
|
+
fulfil: siteFulfil,
|
|
129
|
+
});
|
|
130
|
+
expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
|
|
131
|
+
"fulfil",
|
|
132
|
+
"offerings",
|
|
133
|
+
"site",
|
|
134
|
+
"siteBaseUrl",
|
|
135
|
+
]);
|
|
136
|
+
expect("env" in spokeCheckoutConfig).toBe(false);
|
|
137
|
+
expect(spokeCheckoutConfig.siteBaseUrl).not.toBe("https://elsewhere.example");
|
|
138
|
+
expect(spokeCheckoutConfig.fulfil).not.toBe(siteFulfil);
|
|
139
|
+
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
140
|
+
expect(siteFulfil).not.toHaveBeenCalled();
|
|
141
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
|
|
145
|
+
expect(SOURCE).not.toContain("STRIPE_");
|
|
146
|
+
expect(SOURCE).not.toContain("stripe-client");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("checkoutServed / checkoutNotServed (#8748 D11)", () => {
|
|
151
|
+
it("serves on a spoke only", () => {
|
|
152
|
+
const { checkoutServed } = load();
|
|
153
|
+
mockTopology.value = "spoke";
|
|
154
|
+
expect(checkoutServed()).toBe(true);
|
|
155
|
+
mockTopology.value = "hub";
|
|
156
|
+
expect(checkoutServed()).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("answers 404 with no-store", () => {
|
|
160
|
+
const res = load().checkoutNotServed();
|
|
161
|
+
expect(res.status).toBe(404);
|
|
162
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("the fulfilment (#8748 AC-3)", () => {
|
|
167
|
+
it("posts one lead marked with the site, under the session id as its Idempotency-Key", async () => {
|
|
168
|
+
await load().spokeCheckoutConfig.fulfil(COMPLETION);
|
|
169
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
170
|
+
const [body, init] = mockSubmit.mock.calls[0] as [unknown, unknown];
|
|
171
|
+
expect(body).toEqual({
|
|
172
|
+
siteId: "acme",
|
|
173
|
+
source: "web",
|
|
174
|
+
email: "b@example.com",
|
|
175
|
+
payload: {
|
|
176
|
+
site: "acme",
|
|
177
|
+
page: "checkout",
|
|
178
|
+
offering: "starter",
|
|
179
|
+
checkoutKind: "fixed",
|
|
180
|
+
mode: "test",
|
|
181
|
+
amountTotal: 4900,
|
|
182
|
+
currency: "usd",
|
|
183
|
+
stripeCustomerId: "cus_test_1",
|
|
184
|
+
checkoutSession: "cs_test_1",
|
|
185
|
+
termsAccepted: null,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
expect(init).toEqual({ headers: { "Idempotency-Key": "cs_test_1" } });
|
|
189
|
+
expect(JSON.stringify(body)).toBe(EXPECTED_BODY);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("carries the stamp of the terms the buyer accepted as payload.termsAccepted (#8778)", async () => {
|
|
193
|
+
await load().spokeCheckoutConfig.fulfil({
|
|
194
|
+
...COMPLETION,
|
|
195
|
+
termsOfService: "accepted",
|
|
196
|
+
termsStamp: "account_terms@2",
|
|
197
|
+
});
|
|
198
|
+
const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
|
|
199
|
+
expect(body.payload["termsAccepted"]).toBe("account_terms@2");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it.each([
|
|
203
|
+
["a box not accepted", { termsOfService: null, termsStamp: "account_terms@2" }],
|
|
204
|
+
["an accepted box with no stamp", { termsOfService: "accepted" as const, termsStamp: null }],
|
|
205
|
+
])("sends termsAccepted: null for %s (#8778)", async (_label, terms) => {
|
|
206
|
+
await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, ...terms });
|
|
207
|
+
const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
|
|
208
|
+
expect(body.payload).toHaveProperty("termsAccepted", null);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("sends the same bytes under the same key on a replay", async () => {
|
|
212
|
+
const { spokeCheckoutConfig } = load();
|
|
213
|
+
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
214
|
+
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
215
|
+
expect(mockSubmit).toHaveBeenCalledTimes(2);
|
|
216
|
+
const [first, second] = mockSubmit.mock.calls as [
|
|
217
|
+
[unknown, unknown],
|
|
218
|
+
[unknown, unknown],
|
|
219
|
+
];
|
|
220
|
+
expect(JSON.stringify(second[0])).toBe(JSON.stringify(first[0]));
|
|
221
|
+
expect(second[1]).toEqual(first[1]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("makes no call for a completion without an email", async () => {
|
|
225
|
+
await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, email: null });
|
|
226
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects with no call when AILK_SPOKE_KEY is unset", async () => {
|
|
230
|
+
delete process.env["AILK_SPOKE_KEY"];
|
|
231
|
+
await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
|
|
232
|
+
"AILK_SPOKE_KEY is not set",
|
|
233
|
+
);
|
|
234
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("rejects when the hub answers { ok: false }", async () => {
|
|
238
|
+
mockSubmit.mockResolvedValue({ ok: false, status: 422 });
|
|
239
|
+
await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
|
|
240
|
+
"hub lead write answered 422",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The localized policy map (#8765 D9, AC-5): one map, the registry's keys,
|
|
3
|
+
* each path carrying the locale prefix off the default locale.
|
|
4
|
+
*/
|
|
5
|
+
import { consentPoliciesFor } from "../consent-policies";
|
|
6
|
+
|
|
7
|
+
describe("consentPoliciesFor", () => {
|
|
8
|
+
it("is /contact's three destinations for the default locale", () => {
|
|
9
|
+
expect(consentPoliciesFor("en")).toEqual({
|
|
10
|
+
terms: "/terms",
|
|
11
|
+
privacy: "/privacy",
|
|
12
|
+
working_agreement: "/working-agreement",
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("prefixes every path for another locale", () => {
|
|
17
|
+
expect(consentPoliciesFor("fr")).toEqual({
|
|
18
|
+
terms: "/fr/terms",
|
|
19
|
+
privacy: "/fr/privacy",
|
|
20
|
+
working_agreement: "/fr/working-agreement",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -197,10 +197,15 @@ describe("the registered inputsFor (#8687 D3, AC-2)", () => {
|
|
|
197
197
|
const chrome = siteChrome as typeof siteChrome & {
|
|
198
198
|
deckGrounds?: unknown;
|
|
199
199
|
deckPresentation?: true;
|
|
200
|
+
deckAnchors?: "bare";
|
|
200
201
|
};
|
|
201
202
|
expect(result).toEqual({
|
|
202
203
|
...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
|
|
203
204
|
deckPresentation: chrome.deckPresentation === true,
|
|
205
|
+
// #8773 — the design's slide naming rides along only when it presents and chose bare.
|
|
206
|
+
...(chrome.deckPresentation === true && chrome.deckAnchors === "bare"
|
|
207
|
+
? { deckAnchorStyle: "bare" }
|
|
208
|
+
: {}),
|
|
204
209
|
});
|
|
205
210
|
});
|
|
206
211
|
|
|
@@ -297,11 +297,13 @@ describe("structured data — every content page builds valid JSON-LD", () => {
|
|
|
297
297
|
* exempt route whose page.tsx does render through `renderPage` fails too,
|
|
298
298
|
* because the reason given for it is false.
|
|
299
299
|
*/
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
//
|
|
304
|
-
|
|
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)";
|
|
305
307
|
const CHECKOUT_PAGE_NOINDEX_LINE = "robots: { index: false, follow: false }";
|
|
306
308
|
|
|
307
309
|
const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
@@ -313,9 +315,10 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
|
313
315
|
* entry naming no strings applies to its route unconditionally, as every
|
|
314
316
|
* entry did before. Keys the exemption on the file, not the topology seam:
|
|
315
317
|
* a spoke can carry a hand-written page at this route, and a site can
|
|
316
|
-
* 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.
|
|
317
320
|
*/
|
|
318
|
-
requires?: readonly string[]
|
|
321
|
+
requires?: ReadonlyArray<string | readonly string[]>;
|
|
319
322
|
}> = [
|
|
320
323
|
// Fumadocs routes: JSON-LD is emitted by @working-theory/fumadocs-schema
|
|
321
324
|
// from the fumadocs `.source` collection, which is not built in this
|
|
@@ -388,22 +391,28 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
|
388
391
|
reason: "auth surfaces, not content pages",
|
|
389
392
|
subtree: true,
|
|
390
393
|
},
|
|
391
|
-
// #8513 —
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
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.
|
|
396
399
|
{
|
|
397
400
|
route: "checkout/thank-you",
|
|
398
401
|
reason:
|
|
399
|
-
"spoke checkout return page (
|
|
400
|
-
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
|
+
],
|
|
401
407
|
},
|
|
402
408
|
{
|
|
403
409
|
route: "checkout/unavailable",
|
|
404
410
|
reason:
|
|
405
|
-
"spoke checkout return page (
|
|
406
|
-
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
|
+
],
|
|
407
416
|
},
|
|
408
417
|
];
|
|
409
418
|
|
|
@@ -439,7 +448,9 @@ function exemption(route: string, src: string): string | undefined {
|
|
|
439
448
|
if (entry === undefined) return undefined;
|
|
440
449
|
if (
|
|
441
450
|
entry.requires !== undefined &&
|
|
442
|
-
!entry.requires.every((s) =>
|
|
451
|
+
!entry.requires.every((s) =>
|
|
452
|
+
typeof s === "string" ? src.includes(s) : s.some((any) => src.includes(any)),
|
|
453
|
+
)
|
|
443
454
|
) {
|
|
444
455
|
return undefined;
|
|
445
456
|
}
|
|
@@ -645,6 +656,12 @@ describe("structured data — exemption(route, src) keys the generated checkout
|
|
|
645
656
|
expect(exemption("checkout/unavailable", NO_NOINDEX_SRC)).toBeUndefined();
|
|
646
657
|
});
|
|
647
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
|
+
|
|
648
665
|
it("does not exempt a neighboring checkout route or an unrelated route", () => {
|
|
649
666
|
expect(exemption("checkout/other", GENERATED_SRC)).toBeUndefined();
|
|
650
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
|
+
}
|
|
@@ -55,6 +55,8 @@ export const DECK_ROUTES = [
|
|
|
55
55
|
type Chrome = SiteChrome & {
|
|
56
56
|
deckGrounds?: DeckPageInputs["deckGrounds"];
|
|
57
57
|
deckPresentation?: true;
|
|
58
|
+
/** #8773 — present only when the design chooses bare slide anchors. */
|
|
59
|
+
deckAnchors?: "bare";
|
|
58
60
|
};
|
|
59
61
|
const chrome: Chrome = siteChrome;
|
|
60
62
|
|
|
@@ -180,6 +182,10 @@ async function inputsFor(ctx: DeckPageContext): Promise<DeckPageInputs | null> {
|
|
|
180
182
|
return {
|
|
181
183
|
...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
|
|
182
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
|
+
: {}),
|
|
183
189
|
};
|
|
184
190
|
}
|
|
185
191
|
|