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.
Files changed (101) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/add-route.js +1 -0
  5. package/dist/cli.js +114 -1
  6. package/dist/cutover.d.ts +109 -0
  7. package/dist/cutover.js +494 -0
  8. package/dist/design-presets.js +20 -4
  9. package/dist/export-content.d.ts +55 -0
  10. package/dist/export-content.js +303 -0
  11. package/dist/inventory.d.ts +216 -0
  12. package/dist/inventory.js +833 -0
  13. package/dist/language-import.d.ts +114 -0
  14. package/dist/language-import.js +435 -0
  15. package/dist/lib/env-catalog.js +2 -2
  16. package/dist/lib/import-mdx.d.ts +38 -0
  17. package/dist/lib/import-mdx.js +411 -64
  18. package/dist/lib/render-comparison.d.ts +200 -0
  19. package/dist/lib/render-comparison.js +374 -0
  20. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  21. package/dist/lib/scaffold-render-sweep.js +1 -1
  22. package/dist/migrate-content.d.ts +6 -2
  23. package/dist/migrate-content.js +60 -14
  24. package/dist/module-architecture.js +6 -1
  25. package/dist/provision-env.d.ts +28 -0
  26. package/dist/provision-env.js +210 -0
  27. package/dist/seed-workspace.d.ts +3 -1
  28. package/dist/seed-workspace.js +45 -16
  29. package/dist/surfaces.js +3 -0
  30. package/dist/topology.d.ts +12 -0
  31. package/dist/topology.js +28 -10
  32. package/package.json +3 -3
  33. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  34. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  35. package/templates/.claude/settings.json +9 -0
  36. package/templates/.env.example +7 -0
  37. package/templates/.github/workflows/ci.yml +2 -0
  38. package/templates/CLAUDE.md +5 -0
  39. package/templates/apps/api/CLAUDE.md +6 -2
  40. package/templates/apps/mcp/.env.example +20 -0
  41. package/templates/apps/mcp/CLAUDE.md +46 -8
  42. package/templates/apps/web/.env.example +4 -2
  43. package/templates/apps/web/CLAUDE.md +39 -5
  44. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  47. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  48. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  49. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  50. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  51. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  52. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  53. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  54. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  55. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  56. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  57. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  58. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  59. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  60. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  61. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  62. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  63. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  64. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  65. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  67. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  68. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  69. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  70. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  71. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  72. package/templates/apps/web/app/llms.txt/route.ts +26 -9
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  74. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  75. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  76. package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
  77. package/templates/apps/web/auth.ts +184 -7
  78. package/templates/apps/web/jest.config.cjs +46 -1
  79. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  80. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  81. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  82. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  83. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  84. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  85. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  86. package/templates/apps/web/lib/auth-routes.ts +5 -3
  87. package/templates/apps/web/lib/bot-check.ts +113 -13
  88. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  89. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  90. package/templates/apps/web/lib/page-chrome.ts +65 -5
  91. package/templates/apps/web/lib/relay.ts +22 -3
  92. package/templates/apps/web/lib/site-identity.ts +75 -24
  93. package/templates/apps/web/messages/en.json +47 -14
  94. package/templates/apps/web/next.config.mjs +15 -1
  95. package/templates/apps/web/package.json +1 -1
  96. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  97. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  98. package/templates/database/seed.ts +6 -0
  99. package/templates/package.json +2 -1
  100. package/templates/tools/check-content-source.ts +108 -0
  101. 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 locality = readLocalityLine();
61
- if (locality !== undefined) lines.push({ label: locality });
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 source = await (getMdxSource ?? loadMdxSource)("Event", slug, locale);
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): Record<string, unknown> {
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
- return Object.assign(flat, hiddenAnswers(config, state, flags));
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
- const thankYouSlug = resolveThankYouSlug(thankYou, result.segment);
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
+ });