create-ailk 0.30.0 → 0.31.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 +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
|
@@ -35,6 +35,13 @@
|
|
|
35
35
|
* a `thankYou` block, so without this widening the callback would never be
|
|
36
36
|
* supplied on a recommending flow at all.
|
|
37
37
|
*
|
|
38
|
+
* Per-tier completion redirect (#10616 D4): when a `scoring.tiers[]` entry
|
|
39
|
+
* carries a `thankYou` slug, the client scores the walk's own answers
|
|
40
|
+
* (`resolveTierThankYouSlug`) and navigates to the scored tier's
|
|
41
|
+
* `/thanks/<slug>` AHEAD of the results permalink and the `thankYou` block;
|
|
42
|
+
* a tier with no slug, or a flow with none, continues exactly as before. The
|
|
43
|
+
* answers are read to score, never written into the href.
|
|
44
|
+
*
|
|
38
45
|
* Account link (#8771 D5): when the walk completes on an `invite` magnet (the
|
|
39
46
|
* step response's `next`), the client asks `accountInviteLinkAction` for the
|
|
40
47
|
* sign-in link with the email the visitor gave already filled in, then shows
|
|
@@ -92,6 +99,7 @@ import type {
|
|
|
92
99
|
FlowEndingOutcome,
|
|
93
100
|
FlowState,
|
|
94
101
|
FlowStepDeliverable,
|
|
102
|
+
WaitlistAnswers,
|
|
95
103
|
} from "@working-theory/validation";
|
|
96
104
|
import {
|
|
97
105
|
deriveFlags,
|
|
@@ -107,6 +115,7 @@ import {
|
|
|
107
115
|
pruneHiddenState,
|
|
108
116
|
resolveLocalizedString,
|
|
109
117
|
resolveThankYouSlug,
|
|
118
|
+
resolveTierThankYouSlug,
|
|
110
119
|
selectFlowEndingOutcome,
|
|
111
120
|
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
112
121
|
visitorNameOf,
|
|
@@ -202,12 +211,21 @@ function segmentationQuestionNames(config: FlowConfig): Set<string> {
|
|
|
202
211
|
/**
|
|
203
212
|
* #8840 D9 — the answers the ending is chosen from, assembled as the
|
|
204
213
|
* permalink path assembles them: the visible steps' answers flattened, then
|
|
205
|
-
* the hidden segmentation steps' `whenHidden` answers.
|
|
214
|
+
* the hidden segmentation steps' `whenHidden` answers. Narrowed to the
|
|
215
|
+
* evaluator's string / string[] shape (#10616), which is all any reader keeps.
|
|
206
216
|
*/
|
|
207
|
-
function walkAnswers(config: FlowConfig, state: FlowState):
|
|
217
|
+
function walkAnswers(config: FlowConfig, state: FlowState): WaitlistAnswers {
|
|
208
218
|
const flags = deriveFlags(config, state);
|
|
209
219
|
const flat = flattenState(pruneHiddenState(config, state, flags));
|
|
210
|
-
|
|
220
|
+
const answers: WaitlistAnswers = {};
|
|
221
|
+
for (const [name, value] of Object.entries(
|
|
222
|
+
Object.assign(flat, hiddenAnswers(config, state, flags)),
|
|
223
|
+
)) {
|
|
224
|
+
if (typeof value === "string" || Array.isArray(value)) {
|
|
225
|
+
answers[name] = value as string | string[];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return answers;
|
|
211
229
|
}
|
|
212
230
|
|
|
213
231
|
export function FlowStepperClient({
|
|
@@ -225,6 +243,10 @@ export function FlowStepperClient({
|
|
|
225
243
|
// client never routes to.
|
|
226
244
|
const isRecommending =
|
|
227
245
|
config.mode === "recommending" && config.offers !== undefined;
|
|
246
|
+
// #10616 D4 — a flow whose scoring maps at least one tier to a thank-you
|
|
247
|
+
// page: completion may redirect there, so `onCompleted` is passed.
|
|
248
|
+
const hasTierThankYou =
|
|
249
|
+
config.scoring?.tiers.some((tier) => tier.thankYou !== undefined) === true;
|
|
228
250
|
|
|
229
251
|
// #4770 D1 — the client's own accumulated answers, written from the
|
|
230
252
|
// RESOLVED result of onSubmitStep (never the inbound payload) so a
|
|
@@ -427,6 +449,18 @@ export function FlowStepperClient({
|
|
|
427
449
|
segment?: string;
|
|
428
450
|
aeoScore?: AeoScoreResult;
|
|
429
451
|
}): void {
|
|
452
|
+
// #10616 D4 — a scored tier's thank-you page wins over the permalink and
|
|
453
|
+
// the `thankYou` block. Scored from the walk's answers as the server's
|
|
454
|
+
// completion payload assembles them; no flags are passed (the server
|
|
455
|
+
// scores with none).
|
|
456
|
+
if (hasTierThankYou) {
|
|
457
|
+
const tierSlug = resolveTierThankYouSlug(config, walkAnswers(config, answersRef.current));
|
|
458
|
+
if (tierSlug !== undefined) {
|
|
459
|
+
goToThanks(tierSlug);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
430
464
|
if (isRecommending) {
|
|
431
465
|
// D1 (hidden-step pruning) — apply the shipped visibility resolver to
|
|
432
466
|
// the accumulated state before encoding, so an abandoned branch's
|
|
@@ -481,7 +515,12 @@ export function FlowStepperClient({
|
|
|
481
515
|
}
|
|
482
516
|
|
|
483
517
|
if (!thankYou) return;
|
|
484
|
-
|
|
518
|
+
goToThanks(resolveThankYouSlug(thankYou, result.segment));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// The thank-you redirect's tail, shared by the `thankYou` block and a
|
|
522
|
+
// scored tier's slug (#10616 D4): the invite hand-off, then the push.
|
|
523
|
+
function goToThanks(thankYouSlug: string): void {
|
|
485
524
|
// #8771 D5 — hand the link to /thanks through the per-tab sessionStorage,
|
|
486
525
|
// never the URL; with no link, clear an earlier walk's so it never shows
|
|
487
526
|
// on this page. A storage that throws gives no link.
|
|
@@ -587,7 +626,7 @@ export function FlowStepperClient({
|
|
|
587
626
|
locale={locale}
|
|
588
627
|
messages={messages}
|
|
589
628
|
onSubmitStep={handleSubmitStep}
|
|
590
|
-
{...(!endingWins && (thankYou !== undefined || isRecommending)
|
|
629
|
+
{...(!endingWins && (thankYou !== undefined || isRecommending || hasTierThankYou)
|
|
591
630
|
? { onCompleted: handleCompleted }
|
|
592
631
|
: {})}
|
|
593
632
|
{...(completedAction !== undefined ? { completedAction } : {})}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* Tests for FlowStepperClient's per-tier thank-you redirect on a scored
|
|
4
|
+
* recommending flow (#10616 D4).
|
|
5
|
+
*
|
|
6
|
+
* A SEPARATE file from the six existing `FlowStepperClient.*.test.tsx`
|
|
7
|
+
* suites, which stay UNEDITED as the AC-4 regression floor. It mocks
|
|
8
|
+
* `FlowStepper` the way `FlowStepperClient.permalink.test.tsx` does — a stub
|
|
9
|
+
* that captures the `onSubmitStep`/`onCompleted` props — because this file's
|
|
10
|
+
* scope is the client's own completion wiring, not the stepper.
|
|
11
|
+
*
|
|
12
|
+
* AC-3(b) — a finished walk whose scored tier carries a `thankYou` slug
|
|
13
|
+
* pushes exactly `/<locale>/thanks/<slug>`, ahead of the results
|
|
14
|
+
* permalink; the URL carries no submitted value, no segmentation answer
|
|
15
|
+
* and no session id; an unknown locale falls back to the default.
|
|
16
|
+
* AC-3(a) — the client scores with EMPTY flags (the server's input), so a
|
|
17
|
+
* flag-gated tier never wins even when the walk's own `flags` would set it.
|
|
18
|
+
* AC-3(c) — a flow with tier slugs but no `thankYou` and no `offers` still
|
|
19
|
+
* receives `onCompleted`.
|
|
20
|
+
* AC-4 — a tier with no slug pushes today's permalink; a `thankYou`-only
|
|
21
|
+
* flow pushes `thankYou.default`; an `ending` still wins (no `onCompleted`).
|
|
22
|
+
*/
|
|
23
|
+
import { render } from "@testing-library/react";
|
|
24
|
+
import type { FlowConfig } from "@working-theory/validation";
|
|
25
|
+
import { decodePermalink } from "@working-theory/validation";
|
|
26
|
+
import type { ReactElement } from "react";
|
|
27
|
+
|
|
28
|
+
const mockPush = jest.fn();
|
|
29
|
+
jest.mock("next/navigation", () => ({
|
|
30
|
+
useRouter: () => ({ push: mockPush }),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
jest.mock("next-intl", () => ({
|
|
34
|
+
useTranslations: () => (key: string) => key,
|
|
35
|
+
useMessages: () => ({}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// FlowStepperClient binds onSubmitStep directly to this Server Action, and
|
|
39
|
+
// the boundary under test here is the onCompleted -> router.push wiring, not
|
|
40
|
+
// the action itself (covered by flow.actions.test.ts) — mirrors the sibling
|
|
41
|
+
// suites' identical narrow-scope justification.
|
|
42
|
+
// eslint-disable-next-line no-restricted-syntax -- see comment above
|
|
43
|
+
jest.mock("../flow.actions", () => ({
|
|
44
|
+
submitFlowStepAction: jest.fn(),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
interface MockFlowStepperProps {
|
|
48
|
+
onSubmitStep: (payload: {
|
|
49
|
+
stepKey: string;
|
|
50
|
+
values: Record<string, unknown>;
|
|
51
|
+
locale?: string;
|
|
52
|
+
}) => Promise<{
|
|
53
|
+
ok: boolean;
|
|
54
|
+
kind?: string;
|
|
55
|
+
response?: { completed: boolean; segment?: string };
|
|
56
|
+
}>;
|
|
57
|
+
onCompleted?: (result: { segment?: string }) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let captured: MockFlowStepperProps | undefined;
|
|
61
|
+
|
|
62
|
+
jest.mock("@working-theory/ui-signal-collectors", () => ({
|
|
63
|
+
FlowStepper: (props: MockFlowStepperProps) => {
|
|
64
|
+
captured = props;
|
|
65
|
+
return require("react").createElement("div", {
|
|
66
|
+
"data-testid": "mock-flow-stepper",
|
|
67
|
+
}) as ReactElement;
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
import { submitFlowStepAction } from "../flow.actions";
|
|
72
|
+
import { FlowStepperClient } from "../FlowStepperClient";
|
|
73
|
+
|
|
74
|
+
const mockSubmitFlowStepAction = submitFlowStepAction as jest.MockedFunction<
|
|
75
|
+
typeof submitFlowStepAction
|
|
76
|
+
>;
|
|
77
|
+
|
|
78
|
+
const SLUG = "founder-diagnostic";
|
|
79
|
+
const SESSION_ID = "sess-1a2b3c";
|
|
80
|
+
|
|
81
|
+
type Scoring = NonNullable<FlowConfig["scoring"]>;
|
|
82
|
+
|
|
83
|
+
const PATH_A_ANSWERS = { revenue: "1m-plus", timing: "now" };
|
|
84
|
+
const PATH_B_ANSWERS = { revenue: "under-500k", timing: "exploring" };
|
|
85
|
+
const PII_PAYLOAD = {
|
|
86
|
+
email: "jane@example.com",
|
|
87
|
+
name: "Jane Doe",
|
|
88
|
+
phone: "555-123-4567",
|
|
89
|
+
message: "Please reach out about the enterprise plan as soon as possible",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* path_a: revenue in the top bands AND timing not "exploring" — a rule over
|
|
94
|
+
* two answers, which `thankYou.bySegment` cannot express; path_b is the
|
|
95
|
+
* default. Each tier carries its own ThankYouPage slug.
|
|
96
|
+
*/
|
|
97
|
+
function tierScoring(): Scoring {
|
|
98
|
+
return {
|
|
99
|
+
version: 1,
|
|
100
|
+
points: [
|
|
101
|
+
{
|
|
102
|
+
name: "revenue_points",
|
|
103
|
+
question: "revenue",
|
|
104
|
+
kind: "optionWeights",
|
|
105
|
+
weights: { "under-500k": 0, "500k-1m": 1, "1m-plus": 2 },
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
scores: { total: { sum: ["revenue_points"] } },
|
|
109
|
+
tiers: [
|
|
110
|
+
{
|
|
111
|
+
key: "path_a",
|
|
112
|
+
when: {
|
|
113
|
+
allOf: [
|
|
114
|
+
{ answer: "revenue", in: ["500k-1m", "1m-plus"] },
|
|
115
|
+
{ not: { answer: "timing", eq: "exploring" } },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
thankYou: "path-a-thanks",
|
|
119
|
+
},
|
|
120
|
+
{ key: "path_b", when: { answer: "timing", eq: "exploring" }, thankYou: "path-b-thanks" },
|
|
121
|
+
],
|
|
122
|
+
default: "path_b",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A recommending flow — `offers` plus the scoring above — whose field step
|
|
127
|
+
* submits an email, a name, a phone number and free text. */
|
|
128
|
+
function scoredFlowConfig(overrides: Partial<FlowConfig> = {}): FlowConfig {
|
|
129
|
+
return {
|
|
130
|
+
slug: SLUG,
|
|
131
|
+
defaultLocale: "en",
|
|
132
|
+
mode: "recommending",
|
|
133
|
+
steps: [
|
|
134
|
+
{
|
|
135
|
+
key: "fit",
|
|
136
|
+
type: "segmentation",
|
|
137
|
+
copy: { title: { en: "Fit" } },
|
|
138
|
+
questions: [
|
|
139
|
+
{
|
|
140
|
+
name: "revenue",
|
|
141
|
+
options: [
|
|
142
|
+
{ value: "under-500k", label: { en: "Under $500K" } },
|
|
143
|
+
{ value: "500k-1m", label: { en: "$500K–$1M" } },
|
|
144
|
+
{ value: "1m-plus", label: { en: "$1M+" } },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "timing",
|
|
149
|
+
options: [
|
|
150
|
+
{ value: "now", label: { en: "Now" } },
|
|
151
|
+
{ value: "exploring", label: { en: "Just exploring" } },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
key: "contact",
|
|
158
|
+
type: "field",
|
|
159
|
+
copy: { title: { en: "Contact" } },
|
|
160
|
+
fields: [
|
|
161
|
+
{ name: "email", inputType: "email", required: true, copy: { label: { en: "Email" } } },
|
|
162
|
+
{ name: "name", inputType: "text", required: true, copy: { label: { en: "Name" } } },
|
|
163
|
+
{ name: "phone", inputType: "tel", copy: { label: { en: "Phone" } } },
|
|
164
|
+
{ name: "message", inputType: "textarea", copy: { label: { en: "Message" } } },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
offers: [
|
|
169
|
+
{
|
|
170
|
+
key: "call",
|
|
171
|
+
copy: { name: { en: "A call" } },
|
|
172
|
+
price: { kind: "label", label: { en: "Free" } },
|
|
173
|
+
rules: [{ when: { answer: "revenue", eq: "1m-plus" }, reason: { en: "Ready" } }],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
scoring: tierScoring(),
|
|
177
|
+
...overrides,
|
|
178
|
+
} as FlowConfig;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Render the client, submit the segmentation step then the PII-laden field
|
|
182
|
+
* step (the second completing the walk), and fire `onCompleted`. */
|
|
183
|
+
async function completeWalk(
|
|
184
|
+
config: FlowConfig,
|
|
185
|
+
answers: Record<string, string>,
|
|
186
|
+
locale = "en",
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
mockSubmitFlowStepAction
|
|
189
|
+
.mockResolvedValueOnce({
|
|
190
|
+
ok: true,
|
|
191
|
+
response: { sessionId: SESSION_ID, landed: "fit", nextStepKey: "contact", completed: false },
|
|
192
|
+
})
|
|
193
|
+
.mockResolvedValueOnce({
|
|
194
|
+
ok: true,
|
|
195
|
+
response: { sessionId: SESSION_ID, landed: "contact", nextStepKey: null, completed: true },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
render(<FlowStepperClient config={config} locale={locale} slug={SLUG} />);
|
|
199
|
+
|
|
200
|
+
await captured!.onSubmitStep({ stepKey: "fit", values: answers, locale });
|
|
201
|
+
await captured!.onSubmitStep({ stepKey: "contact", values: PII_PAYLOAD, locale });
|
|
202
|
+
captured!.onCompleted?.({});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function pushedUrl(): string {
|
|
206
|
+
expect(mockPush).toHaveBeenCalledTimes(1);
|
|
207
|
+
return mockPush.mock.calls[0]?.[0] as string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
mockPush.mockClear();
|
|
212
|
+
mockSubmitFlowStepAction.mockClear();
|
|
213
|
+
captured = undefined;
|
|
214
|
+
window.sessionStorage.clear();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("FlowStepperClient — a scored tier's thank-you page replaces the permalink (#10616 AC-3(b))", () => {
|
|
218
|
+
it("path-A answers push exactly /en/thanks/path-a-thanks", async () => {
|
|
219
|
+
await completeWalk(scoredFlowConfig(), PATH_A_ANSWERS);
|
|
220
|
+
expect(pushedUrl()).toBe("/en/thanks/path-a-thanks");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("path-B answers push exactly /en/thanks/path-b-thanks", async () => {
|
|
224
|
+
await completeWalk(scoredFlowConfig(), PATH_B_ANSWERS);
|
|
225
|
+
expect(pushedUrl()).toBe("/en/thanks/path-b-thanks");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("the pushed URL carries no submitted value, no segmentation answer and no session id", async () => {
|
|
229
|
+
await completeWalk(scoredFlowConfig(), PATH_A_ANSWERS);
|
|
230
|
+
// The PII reached onSubmitStep (the wiring forwards real form data) —
|
|
231
|
+
// the assertion is that none of it reaches the push.
|
|
232
|
+
expect(mockSubmitFlowStepAction).toHaveBeenCalledWith(
|
|
233
|
+
SLUG,
|
|
234
|
+
expect.objectContaining({ values: PII_PAYLOAD }),
|
|
235
|
+
);
|
|
236
|
+
const url = pushedUrl();
|
|
237
|
+
expect(url).toMatch(/^\/[A-Za-z-]+\/thanks\/[a-z0-9-]+$/);
|
|
238
|
+
for (const value of [
|
|
239
|
+
...Object.values(PII_PAYLOAD),
|
|
240
|
+
...Object.values(PATH_A_ANSWERS),
|
|
241
|
+
SESSION_ID,
|
|
242
|
+
]) {
|
|
243
|
+
expect(url).not.toContain(value);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("an unknown [locale] falls back to the default locale before interpolation", async () => {
|
|
248
|
+
await completeWalk(scoredFlowConfig(), PATH_A_ANSWERS, "xx");
|
|
249
|
+
expect(pushedUrl()).toBe("/en/thanks/path-a-thanks");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("FlowStepperClient — the client scores with the server's empty flags (#10616 AC-3(a))", () => {
|
|
254
|
+
it("a flag-gated first tier never wins, even when the walk's own flags would set it", async () => {
|
|
255
|
+
const scoring = tierScoring();
|
|
256
|
+
scoring.tiers = [
|
|
257
|
+
{ key: "flagged", when: { flag: "pre_launch" }, thankYou: "flagged-thanks" },
|
|
258
|
+
{ key: "path_b", when: { answer: "timing", eq: "exploring" }, thankYou: "path-b-thanks" },
|
|
259
|
+
];
|
|
260
|
+
const config = scoredFlowConfig({
|
|
261
|
+
flags: [{ name: "pre_launch", when: { answer: "timing", eq: "now" } }],
|
|
262
|
+
scoring,
|
|
263
|
+
});
|
|
264
|
+
await completeWalk(config, PATH_A_ANSWERS);
|
|
265
|
+
expect(pushedUrl()).toBe("/en/thanks/path-b-thanks");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("FlowStepperClient — onCompleted is supplied for a flow with tier slugs alone (#10616 AC-3(c))", () => {
|
|
270
|
+
it("mode recommending with scoring, no offers and no thankYou still receives onCompleted", () => {
|
|
271
|
+
const { offers: _offers, ...noOffers } = scoredFlowConfig();
|
|
272
|
+
render(<FlowStepperClient config={noOffers as FlowConfig} locale="en" slug={SLUG} />);
|
|
273
|
+
expect(captured?.onCompleted).toBeDefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("FlowStepperClient — no mapping is today's behavior (#10616 AC-4)", () => {
|
|
278
|
+
it("(a) a tier with no slug pushes the results permalink, decodable to the segmentation answers", async () => {
|
|
279
|
+
const scoring = tierScoring();
|
|
280
|
+
delete scoring.tiers[1]!.thankYou;
|
|
281
|
+
await completeWalk(scoredFlowConfig({ scoring }), PATH_B_ANSWERS);
|
|
282
|
+
|
|
283
|
+
const match = pushedUrl().match(new RegExp(`^/en/flows/${SLUG}/results/(.+)$`));
|
|
284
|
+
expect(match).not.toBeNull();
|
|
285
|
+
const decoded = decodePermalink(match![1]!);
|
|
286
|
+
expect(decoded.ok).toBe(true);
|
|
287
|
+
if (!decoded.ok) return;
|
|
288
|
+
expect(decoded.value.flowSlug).toBe(SLUG);
|
|
289
|
+
expect(decoded.value.answers).toEqual(PATH_B_ANSWERS);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("(b) a thankYou-only flow with no scoring pushes thankYou.default", async () => {
|
|
293
|
+
const { mode: _mode, offers: _offers, scoring: _scoring, ...plain } = scoredFlowConfig();
|
|
294
|
+
const config = { ...plain, thankYou: { default: "flow-thanks" } } as FlowConfig;
|
|
295
|
+
await completeWalk(config, PATH_A_ANSWERS);
|
|
296
|
+
expect(pushedUrl()).toBe("/en/thanks/flow-thanks");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("(c) a flow with an ending and tier slugs is passed no onCompleted", () => {
|
|
300
|
+
const config = scoredFlowConfig({
|
|
301
|
+
ending: { default: { kind: "screen", headline: { en: "Thanks" } } },
|
|
302
|
+
} as Partial<FlowConfig>);
|
|
303
|
+
render(<FlowStepperClient config={config} locale="en" slug={SLUG} />);
|
|
304
|
+
expect(captured?.onCompleted).toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -25,10 +25,17 @@
|
|
|
25
25
|
// every page in the tree (#4090 item 1).
|
|
26
26
|
import "../globals.css";
|
|
27
27
|
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
|
|
28
31
|
import { Telemetry, VerificationMeta } from "@working-theory/analytics";
|
|
32
|
+
import {
|
|
33
|
+
SiteConsentSchema,
|
|
34
|
+
type ConsentAppearance,
|
|
35
|
+
} from "@working-theory/site-config/schema";
|
|
29
36
|
import { MarketingShell } from "@working-theory/templates/shells";
|
|
30
37
|
import { themeToShellCss } from "@working-theory/theme";
|
|
31
|
-
import { Link, isRtl, localeHref } from "@working-theory/ui";
|
|
38
|
+
import { Link, NewsletterSignupProvider, isRtl, localeHref } from "@working-theory/ui";
|
|
32
39
|
import { BRAND_PATHS, LocaleSwitcherNav } from "@working-theory/ui/blocks";
|
|
33
40
|
import {
|
|
34
41
|
Banner,
|
|
@@ -54,6 +61,7 @@ import { cookies, headers } from "next/headers";
|
|
|
54
61
|
import { NextIntlClientProvider } from "next-intl";
|
|
55
62
|
import { getMessages, getTranslations } from "next-intl/server";
|
|
56
63
|
import type { ReactElement, ReactNode } from "react";
|
|
64
|
+
import { parse as parseYaml } from "yaml";
|
|
57
65
|
|
|
58
66
|
import { GoogleAnalytics } from "../../components/google-analytics";
|
|
59
67
|
import {
|
|
@@ -71,6 +79,8 @@ import {
|
|
|
71
79
|
import {
|
|
72
80
|
inPageView,
|
|
73
81
|
newsletterSignup,
|
|
82
|
+
type InPageView,
|
|
83
|
+
type NewsletterFormView,
|
|
74
84
|
readCaptureStatus,
|
|
75
85
|
readServedMagnet,
|
|
76
86
|
} from "../../lib/in-page-magnet";
|
|
@@ -84,6 +94,7 @@ import { footerColumns, navItems } from "../../lib/nav-config";
|
|
|
84
94
|
import { navMessageKeys, usedNavLabelKeys } from "../../lib/nav-label-keys";
|
|
85
95
|
import { getOgImageUrl } from "../../lib/og";
|
|
86
96
|
import {
|
|
97
|
+
readClosingFooterSettings,
|
|
87
98
|
resolveChromePage,
|
|
88
99
|
resolveHomeChrome,
|
|
89
100
|
resolvePageChrome,
|
|
@@ -98,6 +109,7 @@ import {
|
|
|
98
109
|
COPYRIGHT_HOLDER,
|
|
99
110
|
SITE_DESCRIPTION,
|
|
100
111
|
SITE_NAME,
|
|
112
|
+
resolveContentRoot,
|
|
101
113
|
} from "../../lib/site-brand";
|
|
102
114
|
import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
|
|
103
115
|
import {
|
|
@@ -209,6 +221,38 @@ async function resolveThemeMode(): Promise<ThemeMode> {
|
|
|
209
221
|
const DECK_FIRST_SLIDE_SELECTOR =
|
|
210
222
|
'.ailk-snap-deck .ailk-section-renderer > :not(.ailk-section-separator):not([data-section="separator"])';
|
|
211
223
|
|
|
224
|
+
let warnedConsentAppearance = false;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* `_site.mdx`'s `consent.appearance` (#10555), else `undefined`. The same
|
|
228
|
+
* local frontmatter read as `lib/site-header.ts`, validated against the
|
|
229
|
+
* canonical schema. An absent file, frontmatter or key is silent; an invalid
|
|
230
|
+
* value warns once per process, naming the field path and never the value.
|
|
231
|
+
*/
|
|
232
|
+
function readConsentAppearance(): ConsentAppearance | undefined {
|
|
233
|
+
try {
|
|
234
|
+
const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
|
|
235
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
|
|
236
|
+
if (!match) return undefined;
|
|
237
|
+
const fm: unknown = parseYaml(match[1] ?? "");
|
|
238
|
+
if (!fm || typeof fm !== "object") return undefined;
|
|
239
|
+
const { consent } = fm as { consent?: unknown };
|
|
240
|
+
if (consent === undefined) return undefined;
|
|
241
|
+
const parsed = SiteConsentSchema.safeParse(consent);
|
|
242
|
+
if (parsed.success) return parsed.data.appearance;
|
|
243
|
+
if (!warnedConsentAppearance) {
|
|
244
|
+
warnedConsentAppearance = true;
|
|
245
|
+
const issue = parsed.error.issues[0];
|
|
246
|
+
console.warn(
|
|
247
|
+
`[layout] ignoring _site.mdx ${["consent", ...(issue?.path ?? [])].join(".")}: ${issue?.message ?? "invalid"}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
} catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
212
256
|
/**
|
|
213
257
|
* Whether this visitor has already accepted or declined cookies (#5557).
|
|
214
258
|
*
|
|
@@ -300,12 +344,15 @@ interface Props {
|
|
|
300
344
|
}
|
|
301
345
|
|
|
302
346
|
/**
|
|
303
|
-
* #8847 D13 — the
|
|
304
|
-
* `
|
|
305
|
-
* one-field signup, and the API accepts signups. The
|
|
306
|
-
* never fills
|
|
347
|
+
* #8847 D13 — the newsletter magnet's view for the footer's signup, and
|
|
348
|
+
* (#10629 D4) for every bound `newsletter` section: only when the magnet is
|
|
349
|
+
* served (live), maps as a one-field signup, and the API accepts signups. The
|
|
350
|
+
* seeded built-in config never fills either. Both reads are shared for 15
|
|
351
|
+
* seconds per instance.
|
|
307
352
|
*/
|
|
308
|
-
async function
|
|
353
|
+
async function servedNewsletterView(
|
|
354
|
+
locale: string,
|
|
355
|
+
): Promise<(InPageView & { form: NewsletterFormView }) | undefined> {
|
|
309
356
|
const [magnet, status] = await Promise.all([
|
|
310
357
|
readServedMagnet(NEWSLETTER_FLOW_SLUG),
|
|
311
358
|
readCaptureStatus(),
|
|
@@ -313,11 +360,7 @@ async function footerNewsletter(locale: string): Promise<ReactElement | undefine
|
|
|
313
360
|
if (magnet === null || status?.newsletter !== true) return undefined;
|
|
314
361
|
const view = inPageView(magnet, locale, "newsletter");
|
|
315
362
|
if (view === null || !("kind" in view.form)) return undefined;
|
|
316
|
-
return
|
|
317
|
-
<div data-testid="footer-newsletter">
|
|
318
|
-
{newsletterSignup({ ...view, form: view.form }, { locale, compact: true })}
|
|
319
|
-
</div>
|
|
320
|
-
);
|
|
363
|
+
return { ...view, form: view.form };
|
|
321
364
|
}
|
|
322
365
|
|
|
323
366
|
export default async function LocaleLayout({
|
|
@@ -336,6 +379,11 @@ export default async function LocaleLayout({
|
|
|
336
379
|
// #7742 D3/D5 — the minimal chrome: the closing slide, the hairline consent
|
|
337
380
|
// bar and the cue offset.
|
|
338
381
|
const isMinimalChrome = chrome.chromeStyle === "minimal";
|
|
382
|
+
// #10555 — `_site.mdx`'s `consent.appearance` picks the cookie notice's
|
|
383
|
+
// look; absent, the chrome does (hairline under minimal, else compact).
|
|
384
|
+
const isHairlineConsent =
|
|
385
|
+
(readConsentAppearance() ?? (isMinimalChrome ? "hairline" : "compact")) ===
|
|
386
|
+
"hairline";
|
|
339
387
|
const consentDecided = await hasConsentChoice();
|
|
340
388
|
// Server-side session read (#4984) — the same `auth.api.getSession` idiom
|
|
341
389
|
// the `(authed)` segment guard uses, through the generated topology seam
|
|
@@ -343,7 +391,26 @@ export default async function LocaleLayout({
|
|
|
343
391
|
// Null when signed out or auth is unset.
|
|
344
392
|
const session = await readSession(await headers());
|
|
345
393
|
|
|
346
|
-
const
|
|
394
|
+
const newsletterView = await servedNewsletterView(locale);
|
|
395
|
+
// #8847 D13 — the footer's compact signup.
|
|
396
|
+
const footerSignup =
|
|
397
|
+
newsletterView === undefined ? undefined : (
|
|
398
|
+
<div data-testid="footer-newsletter">
|
|
399
|
+
{newsletterSignup(newsletterView, { locale, compact: true })}
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
// #10629 D4 — the full-size signup every bound `newsletter` section renders
|
|
403
|
+
// (as on /newsletter), with the on-submit consent gate. Both shell branches
|
|
404
|
+
// render the page under its provider, so a bound section on any page reads it.
|
|
405
|
+
const page = (
|
|
406
|
+
<NewsletterSignupProvider
|
|
407
|
+
{...(newsletterView !== undefined && {
|
|
408
|
+
signup: newsletterSignup(newsletterView, { locale, consentGate: "on-submit" }),
|
|
409
|
+
})}
|
|
410
|
+
>
|
|
411
|
+
{children}
|
|
412
|
+
</NewsletterSignupProvider>
|
|
413
|
+
);
|
|
347
414
|
|
|
348
415
|
// Resolve nav labels server-side so Nav + Footer components stay
|
|
349
416
|
// i18n-hook-free and are testable without a NextIntlClientProvider wrapper.
|
|
@@ -486,6 +553,22 @@ export default async function LocaleLayout({
|
|
|
486
553
|
const deckFooterActions = session
|
|
487
554
|
? [{ labelKey: "account", path: "/account", variant: "outline-strong" as const }]
|
|
488
555
|
: [...authActions];
|
|
556
|
+
// #10550 D4 — the closing footer's site settings: its locality line, its
|
|
557
|
+
// actions (the primary, then the secondary — they replace the auth links
|
|
558
|
+
// on the closing footer in every header state) and its legal bar. The
|
|
559
|
+
// site's labels ride `itemLabels` under keys no nav key can collide with
|
|
560
|
+
// (no nav key contains a dot).
|
|
561
|
+
const closingFooter = readClosingFooterSettings();
|
|
562
|
+
const closingFooterActions = closingFooter.actions?.map(
|
|
563
|
+
(action, i) => ({
|
|
564
|
+
labelKey: `closingFooter.action.${i}`,
|
|
565
|
+
path: action.href,
|
|
566
|
+
variant: i === 0 ? ("primary" as const) : ("ghost" as const),
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
const closingFooterLabels = Object.fromEntries(
|
|
570
|
+
(closingFooter.actions ?? []).map((action, i) => [`closingFooter.action.${i}`, action.label]),
|
|
571
|
+
);
|
|
489
572
|
// A bare inner page's auth (#7721 D3/D4) rides the one-bar footer's link
|
|
490
573
|
// group: Sign in / Sign up signed out, Account signed in (where Sign out
|
|
491
574
|
// lives). Labels from the existing `nav` namespace. Under a full header
|
|
@@ -788,7 +871,7 @@ export default async function LocaleLayout({
|
|
|
788
871
|
* consent Banner below. The booking paths (#8843 D10) and a magnet
|
|
789
872
|
* walk (#8840 D10) take it and keep the Banner.
|
|
790
873
|
*/
|
|
791
|
-
|
|
874
|
+
page
|
|
792
875
|
) : (
|
|
793
876
|
<MarketingShell
|
|
794
877
|
contentOwnsMain
|
|
@@ -925,13 +1008,23 @@ export default async function LocaleLayout({
|
|
|
925
1008
|
// #10049 D5 — the style is `pageChrome.footer`, and every
|
|
926
1009
|
// footer with an actions row takes the auth links when the
|
|
927
1010
|
// header is not full.
|
|
1011
|
+
// #10550 D4 — a site's own `footer.locality`, `footer.actions`
|
|
1012
|
+
// and `footer.legalBar` set the closing footer's locality line,
|
|
1013
|
+
// its actions (in place of the auth links, whatever the header)
|
|
1014
|
+
// and its legal bar.
|
|
928
1015
|
{...(pageChrome.footer === "closing"
|
|
929
1016
|
? {
|
|
930
1017
|
layout: "closing" as const,
|
|
931
|
-
contactLines: readSiteContactLines(),
|
|
932
|
-
...(
|
|
933
|
-
? {
|
|
934
|
-
|
|
1018
|
+
contactLines: readSiteContactLines(closingFooter.locality),
|
|
1019
|
+
...(closingFooterActions !== undefined
|
|
1020
|
+
? {
|
|
1021
|
+
actions: closingFooterActions,
|
|
1022
|
+
itemLabels: { ...navLabels.items, ...closingFooterLabels },
|
|
1023
|
+
}
|
|
1024
|
+
: !headerIsFull && !(isHome && isMinimalChrome)
|
|
1025
|
+
? { actions: deckFooterActions }
|
|
1026
|
+
: {}),
|
|
1027
|
+
...(closingFooter.legalBar ? { legalBar: true } : {}),
|
|
935
1028
|
}
|
|
936
1029
|
: pageChrome.footer === "line"
|
|
937
1030
|
? { layout: "line" as const, accountLinks }
|
|
@@ -967,7 +1060,7 @@ export default async function LocaleLayout({
|
|
|
967
1060
|
)
|
|
968
1061
|
}
|
|
969
1062
|
>
|
|
970
|
-
{
|
|
1063
|
+
{page}
|
|
971
1064
|
</MarketingShell>
|
|
972
1065
|
)}
|
|
973
1066
|
{/*
|
|
@@ -996,11 +1089,11 @@ export default async function LocaleLayout({
|
|
|
996
1089
|
consentCookieName={CONSENT_COOKIE_NAME}
|
|
997
1090
|
consentDecided={consentDecided}
|
|
998
1091
|
title={tConsent("title")}
|
|
999
|
-
// #9656 D7 — the
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
{...(
|
|
1092
|
+
// #9656 D7 — the compact floating card on every width, its
|
|
1093
|
+
// Privacy Policy link on the button row; the hairline band
|
|
1094
|
+
// (#7742 D5), the statement with the link inline. #10555 —
|
|
1095
|
+
// which one is `isHairlineConsent`'s, not the chrome's.
|
|
1096
|
+
{...(isHairlineConsent
|
|
1004
1097
|
? {
|
|
1005
1098
|
message: (
|
|
1006
1099
|
<>
|
|
@@ -1038,12 +1131,14 @@ export default async function LocaleLayout({
|
|
|
1038
1131
|
// (`CONSENT_COOKIE_NAME`) stays the operative switch the
|
|
1039
1132
|
// island itself still writes (C3).
|
|
1040
1133
|
locale={locale}
|
|
1041
|
-
// #7742 D5 — the
|
|
1042
|
-
//
|
|
1043
|
-
{...(
|
|
1134
|
+
// #7742 D5 — the hairline consent bar: the page ground, a
|
|
1135
|
+
// hairline, no shadow.
|
|
1136
|
+
{...(isHairlineConsent ? { consentAppearance: "hairline" as const } : {})}
|
|
1044
1137
|
// #7782 — on the minimal deck home the bar first shows once the
|
|
1045
1138
|
// first slide has left view; every other page shows it on load.
|
|
1046
|
-
|
|
1139
|
+
// #10721 — the bar's alone: a minimal site that picks the card
|
|
1140
|
+
// (`consent.appearance: compact`) shows it on load there too.
|
|
1141
|
+
{...(isDeckHome && isMinimalChrome && isHairlineConsent
|
|
1047
1142
|
? { consentRevealAfter: DECK_FIRST_SLIDE_SELECTOR }
|
|
1048
1143
|
: {})}
|
|
1049
1144
|
/>
|