create-ailk 0.29.1 → 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/add-route.js +1 -0
- package/dist/cli.js +114 -1
- 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 +2 -2
- 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/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -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/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- 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]/about/__tests__/about-routes.test.tsx +2 -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/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +26 -9
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -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/auth-routes.ts +5 -3
- 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/mcp-oauth.ts +702 -0
- 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 +47 -14
- 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/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* read through the typed `SiteIdentitySchema` (#7824), so an invalid
|
|
9
9
|
* identity or address gives no line rather than a half-trusted one;
|
|
10
10
|
* 2. the published contact email (`readSiteContactEmail`, the address
|
|
11
|
-
* `/contact` shows), as a `mailto:` link
|
|
11
|
+
* `/contact` shows), as a `mailto:` link;
|
|
12
|
+
* 3. a site's own `footer.locality` (#10550 D2), passed in by the layout,
|
|
13
|
+
* which replaces line 1 when it is non-blank.
|
|
12
14
|
*
|
|
13
15
|
* Either line is left out when the site has none, so a site shows zero, one
|
|
14
16
|
* or two lines. The same local `_site.mdx` frontmatter read as its sibling
|
|
@@ -54,11 +56,11 @@ function readLocalityLine(): string | undefined {
|
|
|
54
56
|
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
export function readSiteContactLines(): SiteContactLine[] {
|
|
59
|
+
export function readSiteContactLines(locality?: string): SiteContactLine[] {
|
|
58
60
|
try {
|
|
59
61
|
const lines: SiteContactLine[] = [];
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
+
const localityLine = nonEmpty(locality) ?? readLocalityLine();
|
|
63
|
+
if (localityLine !== undefined) lines.push({ label: localityLine });
|
|
62
64
|
const email = readSiteContactEmail();
|
|
63
65
|
if (email !== undefined) lines.push({ label: email, href: `mailto:${email}` });
|
|
64
66
|
return lines;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Event detail page. Reads the Event MDX for the given slug, renders via
|
|
2
2
|
// renderPage, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
|
|
3
|
+
// An Event under a program (`parent`) lives at /<program>/<event> (#10552), so
|
|
4
|
+
// this URL answers a 308 there.
|
|
3
5
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
4
6
|
import {
|
|
5
7
|
getMdxSource as loadMdxSource,
|
|
@@ -7,6 +9,7 @@ import {
|
|
|
7
9
|
renderPageMetadata,
|
|
8
10
|
} from "@working-theory/page-renderer";
|
|
9
11
|
import type { Metadata } from "next";
|
|
12
|
+
import { permanentRedirect } from "next/navigation";
|
|
10
13
|
import type { ReactElement } from "react";
|
|
11
14
|
|
|
12
15
|
interface Props {
|
|
@@ -29,7 +32,16 @@ export default async function Page({
|
|
|
29
32
|
// A page that composes `sections[]` renders them (JSON-LD is built from
|
|
30
33
|
// the same sections); a prose page renders its MDX body — the rule
|
|
31
34
|
// /vs/[slug] applies (#10122).
|
|
32
|
-
const
|
|
35
|
+
const load = getMdxSource ?? loadMdxSource;
|
|
36
|
+
const source = await load("Event", slug, locale);
|
|
37
|
+
const parent = source?.frontmatter.parent;
|
|
38
|
+
// Only to a program the program route serves (a LandingPage at /<parent>),
|
|
39
|
+
// so a mistyped `parent` never turns this URL into a dead redirect.
|
|
40
|
+
if (parent !== undefined && (await load("LandingPage", parent, locale)) !== null) {
|
|
41
|
+
const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "en";
|
|
42
|
+
const prefix = locale === defaultLocale ? "" : `/${locale}`;
|
|
43
|
+
permanentRedirect(`${prefix}/${parent}/${slug}`);
|
|
44
|
+
}
|
|
33
45
|
const composed =
|
|
34
46
|
source !== null &&
|
|
35
47
|
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #10552 AC-6 — an Event under a program has one URL, `/<program>/<event>`:
|
|
3
|
+
* `/events/<event>` answers a 308 there, and the /events hub's card links it
|
|
4
|
+
* directly. An Event with no `parent` keeps `/events/<event>` on both.
|
|
5
|
+
*
|
|
6
|
+
* Its own suite: `hub-children.test.tsx` mocks `@working-theory/page-renderer`
|
|
7
|
+
* whole, so the hub's real cards are not rendered there.
|
|
8
|
+
*/
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { render } from "@testing-library/react";
|
|
11
|
+
|
|
12
|
+
jest.mock("next/navigation", () => ({
|
|
13
|
+
__esModule: true,
|
|
14
|
+
notFound: jest.fn(() => {
|
|
15
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
16
|
+
}),
|
|
17
|
+
permanentRedirect: jest.fn((url: string) => {
|
|
18
|
+
throw new Error(`NEXT_REDIRECT ${url}`);
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { permanentRedirect } from "next/navigation";
|
|
23
|
+
|
|
24
|
+
import EventsPage from "../page";
|
|
25
|
+
import EventSlugPage from "../[slug]/page";
|
|
26
|
+
|
|
27
|
+
const mockRedirect = permanentRedirect as jest.MockedFunction<typeof permanentRedirect>;
|
|
28
|
+
|
|
29
|
+
const event = (slug: string, parent?: string) => ({
|
|
30
|
+
frontmatter: {
|
|
31
|
+
type: "Event" as const,
|
|
32
|
+
slug,
|
|
33
|
+
title: `Title for ${slug}`,
|
|
34
|
+
description: `Description for ${slug}.`,
|
|
35
|
+
...(parent !== undefined && { parent }),
|
|
36
|
+
},
|
|
37
|
+
body: `# Title for ${slug}\n\nBody.`,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const eventsList = {
|
|
41
|
+
frontmatter: { type: "EventsList" as const, slug: "events", title: "Events", description: "Events." },
|
|
42
|
+
body: "# Events\n\nBody.",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const events: Record<string, ReturnType<typeof event>> = {
|
|
46
|
+
"golf-tournament": event("golf-tournament", "younglife"),
|
|
47
|
+
"annual-summit": event("annual-summit"),
|
|
48
|
+
"typo-gala": event("typo-gala", "no-such-program"),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const younglife = {
|
|
52
|
+
frontmatter: { type: "LandingPage" as const, slug: "younglife", title: "Young Life", description: "YL." },
|
|
53
|
+
body: "",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const getMdxSource = jest.fn(async (type: string, slug: string) =>
|
|
57
|
+
type === "Event"
|
|
58
|
+
? (events[slug] ?? null)
|
|
59
|
+
: type === "EventsList"
|
|
60
|
+
? eventsList
|
|
61
|
+
: type === "LandingPage" && slug === "younglife"
|
|
62
|
+
? younglife
|
|
63
|
+
: null,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
describe("/events/<event> for an Event under a program (#10552 D3)", () => {
|
|
67
|
+
const savedDefault = process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
mockRedirect.mockClear();
|
|
70
|
+
delete process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
|
|
71
|
+
});
|
|
72
|
+
afterAll(() => {
|
|
73
|
+
if (savedDefault === undefined) delete process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
|
|
74
|
+
else process.env.NEXT_PUBLIC_DEFAULT_LOCALE = savedDefault;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it.each([
|
|
78
|
+
["en", "/younglife/golf-tournament"],
|
|
79
|
+
["es", "/es/younglife/golf-tournament"],
|
|
80
|
+
])("answers a 308 to the program URL at %s", async (locale, target) => {
|
|
81
|
+
await expect(
|
|
82
|
+
EventSlugPage({ params: Promise.resolve({ locale, slug: "golf-tournament" }), getMdxSource }),
|
|
83
|
+
).rejects.toThrow(`NEXT_REDIRECT ${target}`);
|
|
84
|
+
expect(mockRedirect).toHaveBeenCalledWith(target);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not redirect when the parent names no program page", async () => {
|
|
88
|
+
render(
|
|
89
|
+
await EventSlugPage({ params: Promise.resolve({ locale: "en", slug: "typo-gala" }), getMdxSource }),
|
|
90
|
+
);
|
|
91
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("renders an Event with no parent, with no redirect", async () => {
|
|
95
|
+
const { container } = render(
|
|
96
|
+
await EventSlugPage({
|
|
97
|
+
params: Promise.resolve({ locale: "en", slug: "annual-summit" }),
|
|
98
|
+
getMdxSource,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
102
|
+
expect(container.querySelector("script[type='application/ld+json']")).not.toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("the /events hub's card for an Event under a program (#10552 D5)", () => {
|
|
107
|
+
it("links the program URL, and /events/<slug> for an Event with no parent", async () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
await EventsPage({
|
|
110
|
+
params: Promise.resolve({ locale: "en" }),
|
|
111
|
+
getMdxSlugs: async () => ["golf-tournament", "annual-summit"],
|
|
112
|
+
getMdxSource,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
const hrefs = Array.from(container.querySelectorAll("a")).map((a) => a.getAttribute("href"));
|
|
116
|
+
expect(hrefs).toContain("/younglife/golf-tournament");
|
|
117
|
+
expect(hrefs).not.toContain("/events/golf-tournament");
|
|
118
|
+
expect(hrefs).toContain("/events/annual-summit");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -12,6 +12,7 @@ import type { Metadata } from "next";
|
|
|
12
12
|
import type { ReactElement } from "react";
|
|
13
13
|
|
|
14
14
|
import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
|
|
15
|
+
import { pagePathFor } from "../../../src/lib/routes";
|
|
15
16
|
|
|
16
17
|
interface Props {
|
|
17
18
|
params: Promise<{ locale: string }>;
|
|
@@ -27,6 +28,16 @@ const HUB: HubConfig = {
|
|
|
27
28
|
linkLabel: "View the event",
|
|
28
29
|
sectionId: "event-cards",
|
|
29
30
|
body: "mdx-body",
|
|
31
|
+
// #10552 D5 — an Event under a program links its program URL; the rest is
|
|
32
|
+
// the default card, byte for byte.
|
|
33
|
+
card: ({ slug, frontmatter }) => {
|
|
34
|
+
const fm = frontmatter as { title?: string; description?: string; parent?: string };
|
|
35
|
+
return {
|
|
36
|
+
title: fm.title ?? slug,
|
|
37
|
+
description: fm.description ?? "",
|
|
38
|
+
link: { label: HUB.linkLabel, href: pagePathFor({ type: "Event", slug, parent: fm.parent }) },
|
|
39
|
+
};
|
|
40
|
+
},
|
|
30
41
|
};
|
|
31
42
|
|
|
32
43
|
export async function generateMetadata({
|
|
@@ -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
|
+
});
|