create-ailk 0.31.0 → 0.33.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 (86) hide show
  1. package/README.md +50 -8
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cutover.d.ts +6 -1
  7. package/dist/cutover.js +23 -6
  8. package/dist/export-content.d.ts +6 -3
  9. package/dist/export-content.js +79 -10
  10. package/dist/language-import.d.ts +16 -3
  11. package/dist/language-import.js +27 -11
  12. package/dist/lib/import-mdx.d.ts +42 -0
  13. package/dist/lib/import-mdx.js +584 -65
  14. package/dist/lib/render-comparison.d.ts +51 -0
  15. package/dist/lib/render-comparison.js +205 -6
  16. package/dist/mdx-braces.d.ts +73 -0
  17. package/dist/mdx-braces.js +285 -0
  18. package/dist/migrate-content.d.ts +1 -1
  19. package/dist/migrate-content.js +4 -2
  20. package/dist/programmatic.js +2 -8
  21. package/dist/seed-flows.d.ts +1 -1
  22. package/dist/seed-flows.js +9 -4
  23. package/dist/site-manifest.js +6 -38
  24. package/dist/site-roster.d.ts +46 -3
  25. package/dist/site-roster.js +124 -4
  26. package/dist/surfaces.d.ts +8 -0
  27. package/dist/surfaces.js +17 -0
  28. package/dist/update.d.ts +21 -1
  29. package/dist/update.js +142 -11
  30. package/package.json +3 -3
  31. package/templates/.github/workflows/ci.yml +2 -0
  32. package/templates/apps/api/CLAUDE.md +7 -5
  33. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  34. package/templates/apps/mcp/.env.example +7 -1
  35. package/templates/apps/web/__tests__/setup.ts +9 -0
  36. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  37. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  38. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  39. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  40. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  43. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  44. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  45. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  46. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  47. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  48. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  49. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  50. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  51. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  52. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  53. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  54. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  55. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  56. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  57. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  58. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  59. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  60. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  61. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  67. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  68. package/templates/apps/web/auth.ts +22 -6
  69. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  70. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  71. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
  72. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
  73. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  74. package/templates/apps/web/lib/account-config.ts +52 -14
  75. package/templates/apps/web/lib/auth-email.ts +87 -5
  76. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  77. package/templates/apps/web/messages/en.json +26 -1
  78. package/templates/apps/web/src/lib/routes.ts +5 -3
  79. package/templates/database/seed.ts +7 -7
  80. package/templates/package.json +2 -1
  81. package/templates/tools/body-heading.ts +440 -0
  82. package/templates/tools/check-mdx-body.ts +123 -0
  83. package/templates/tools/mdx-braces.ts +303 -0
  84. package/versions.json +48 -48
  85. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  86. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -1,31 +1,41 @@
1
1
  /**
2
- * `/[locale]/verify-email` — the check-your-email terminus (#5963, wired by #5962).
2
+ * `/[locale]/verify-email` — the check-your-email terminus (#5963, wired by
3
+ * #5962) and the sign-up confirm page (#10820 D7).
3
4
  *
4
- * Two states, both copy-only:
5
- * - no `?error` → the `sent` intent. What #5963 shipped: this page states
6
- * what just happened.
7
- * - `?error=…` → the same expired/invalid-token state `/reset-password`
8
- * renders. Better Auth's `/verify-email` redirects to its
9
- * callback with `?error=<code>` when the JWT is expired,
10
- * malformed or signed with a rotated secret.
5
+ * Four states, in this precedence:
6
+ * - `?verify=<nonce>` → a confirm card holding ONE button. The GET writes
7
+ * nothing: the button posts to `confirmEmailAction`, bound
8
+ * to the nonce (no hidden input carries it), so a mail
9
+ * scanner or a link preview that fetches the link verifies
10
+ * nothing — only a person's click does.
11
+ * - `?confirmed=1` → the verified card, with the Sign in link.
12
+ * - `?error=…` → the same expired/invalid-link state `/reset-password`
13
+ * renders: the action's answer for an expired, unknown or
14
+ * refused confirm.
15
+ * - no query → the `sent` intent: this page states what just happened.
11
16
  *
12
- * The verification token's properties are NOT the reset token's, and the copy
13
- * is written accordingly: it is a signed JWT rather than a row, so it is
14
- * neither single-use nor revocable — the same link keeps working until it
15
- * expires. That asymmetry is acceptable precisely because its blast radius is
16
- * `emailVerified: true` on one row rather than a credential.
17
+ * The link is the API's single-use nonce (`services/email-verification.ts`):
18
+ * one confirm spends it, and it expires after 24 hours.
17
19
  */
18
20
 
19
21
  import { localeHref } from "@working-theory/ui";
20
22
  import { AuthCard, type AuthCardLabels } from "@working-theory/ui/blocks";
23
+ import { Button, Text } from "@working-theory/ui/primitives";
21
24
  import type { Metadata } from "next";
22
25
  import { getTranslations } from "next-intl/server";
23
26
  import type { ReactElement } from "react";
27
+
24
28
  import { siteLogo } from "../../../../lib/site-logo";
25
29
 
30
+ import { confirmEmailAction } from "./actions";
31
+
26
32
  interface Props {
27
33
  params: Promise<{ locale: string }>;
28
- searchParams?: Promise<{ error?: string | string[] }>;
34
+ searchParams?: Promise<{
35
+ error?: string | string[];
36
+ verify?: string | string[];
37
+ confirmed?: string | string[];
38
+ }>;
29
39
  }
30
40
 
31
41
  function firstValue(raw: string | string[] | undefined): string {
@@ -52,6 +62,61 @@ export default async function VerifyEmailPage({
52
62
 
53
63
  const query = searchParams ? await searchParams : {};
54
64
  const error = firstValue(query.error);
65
+ const nonce = firstValue(query.verify);
66
+
67
+ if (nonce.length >= 1 && nonce.length <= 256) {
68
+ const labels: AuthCardLabels = {
69
+ sentHeading: t("confirmHeading"),
70
+ sentSubhead: t("confirmSubhead"),
71
+ backToSignIn: t("backToSignIn"),
72
+ };
73
+ return (
74
+ <div className="flex flex-col gap-4" data-testid="verify-email-page">
75
+ <div data-testid="verify-email-confirm">
76
+ <AuthCard
77
+ logo={siteLogo()}
78
+ intent="sent"
79
+ signInHref={signInHref}
80
+ labels={labels}
81
+ body={
82
+ <form
83
+ action={confirmEmailAction.bind(null, nonce, locale)}
84
+ className="flex flex-col gap-5 text-center"
85
+ >
86
+ <Text variant="body-sm" tone="muted">
87
+ {t("confirmBody")}
88
+ </Text>
89
+ <Button
90
+ type="submit"
91
+ size="big"
92
+ className="w-full"
93
+ data-testid="verify-email-confirm-button"
94
+ >
95
+ {t("confirmButton")}
96
+ </Button>
97
+ </form>
98
+ }
99
+ />
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ if (firstValue(query.confirmed) === "1") {
106
+ const labels: AuthCardLabels = {
107
+ sentHeading: t("verifiedHeading"),
108
+ sentSubhead: t("verifiedSubhead"),
109
+ sentBody: t("verifiedBody"),
110
+ backToSignIn: t("backToSignIn"),
111
+ };
112
+ return (
113
+ <div className="flex flex-col gap-4" data-testid="verify-email-page">
114
+ <div data-testid="verify-email-confirmed">
115
+ <AuthCard logo={siteLogo()} intent="sent" signInHref={signInHref} labels={labels} />
116
+ </div>
117
+ </div>
118
+ );
119
+ }
55
120
 
56
121
  if (error) {
57
122
  const labels: AuthCardLabels = {
@@ -124,6 +124,13 @@ jest.mock("@working-theory/api-client", () => ({
124
124
  messages: (...args: unknown[]) => mockMemberRead(...args),
125
125
  purchases: (...args: unknown[]) => mockMemberPurchases(...args),
126
126
  appointments: (...args: unknown[]) => mockMemberAppointments(...args),
127
+ // #10826 D7 — the user-magnet results read; 404 here (no workspace module).
128
+ results: () =>
129
+ Promise.resolve({
130
+ ok: false,
131
+ status: 404,
132
+ body: { ok: false, error: { code: "not_found", message: "Not found" } },
133
+ }),
127
134
  updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
128
135
  requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
129
136
  // #8217 — only the rendered billing action reads it; billing-source.ts never does.
@@ -72,6 +72,13 @@ jest.mock("@working-theory/api-client", () => ({
72
72
  messages: (...args: unknown[]) => mockMemberMessages(...args),
73
73
  purchases: (...args: unknown[]) => mockMemberPurchases(...args),
74
74
  appointments: (...args: unknown[]) => mockMemberAppointments(...args),
75
+ // #10826 D7 — the user-magnet results read; 404 here (no workspace module).
76
+ results: () =>
77
+ Promise.resolve({
78
+ ok: false,
79
+ status: 404,
80
+ body: { ok: false, error: { code: "not_found", message: "Not found" } },
81
+ }),
75
82
  meetingTypes: (...args: unknown[]) => mockMemberMeetingTypes(...args),
76
83
  requestForm: (...args: unknown[]) => mockMemberRequestForm(...args),
77
84
  },
@@ -100,6 +100,13 @@ jest.mock("@working-theory/api-client", () => ({
100
100
  messages: (...args: unknown[]) => mockMemberMessages(...args),
101
101
  purchases: (...args: unknown[]) => mockMemberPurchases(...args),
102
102
  appointments: (...args: unknown[]) => mockMemberAppointments(...args),
103
+ // #10826 D7 — the user-magnet results read; 404 here (no workspace module).
104
+ results: () =>
105
+ Promise.resolve({
106
+ ok: false,
107
+ status: 404,
108
+ body: { ok: false, error: { code: "not_found", message: "Not found" } },
109
+ }),
103
110
  // #8765 D8 — the retired card's read; the page must not depend on it.
104
111
  consents: (...args: unknown[]) => mockMemberConsents(...args),
105
112
  // #8233 C4 — the New menu's availability reads; 404 here, so the Inbox
@@ -105,6 +105,9 @@ import { notFound } from "next/navigation";
105
105
  import { listingExtension } from "../../../../../lib/listings";
106
106
  import DetailPage, { generateMetadata } from "../page";
107
107
 
108
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
109
+ process.env.CONTENT_ADAPTER = "mdx";
110
+
108
111
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
109
112
 
110
113
  function makeSource(slug: string) {
@@ -45,6 +45,9 @@ jest.mock("@working-theory/ui", () => ({
45
45
  import { notFound } from "next/navigation";
46
46
  import SlugPage from "../page";
47
47
 
48
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
49
+ process.env.CONTENT_ADAPTER = "mdx";
50
+
48
51
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
49
52
 
50
53
  function makeSource(slug: string, schema?: Record<string, unknown>) {
@@ -45,6 +45,9 @@ import { createSitemapHandler } from "../../../sitemap.xml/route";
45
45
  import { createLlmsHandler } from "../../../llms.txt/route";
46
46
  import type { ContentAdapter } from "@working-theory/content-adapters";
47
47
 
48
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
49
+ process.env.CONTENT_ADAPTER = "mdx";
50
+
48
51
  type JsonLd = Record<string, unknown>;
49
52
 
50
53
  function jsonLdScripts(container: HTMLElement): JsonLd[] {
@@ -58,6 +58,9 @@ import CareersPage from "../page";
58
58
  import CareersSlugPage from "../[slug]/page";
59
59
  import * as pageRenderer from "@working-theory/page-renderer";
60
60
 
61
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
62
+ process.env.CONTENT_ADAPTER = "mdx";
63
+
61
64
  // ── Shared stubs ───────────────────────────────────────────────────────────────
62
65
  const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
63
66
 
@@ -935,6 +935,27 @@ describe("captureLeadAction — a checkout magnet answers the Offerings buy rout
935
935
  },
936
936
  );
937
937
 
938
+ it("a stored lookup key is the key itself, never `<key>_monthly` (#10823)", async () => {
939
+ answers({ completion: "checkout", checkoutOffering: "growth-ops-growth_yearly" });
940
+ shows({ status: "shown", shownSet: shownSet(["monthly", "yearly"], "growth-ops-growth") });
941
+
942
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
943
+ kind: "success",
944
+ name: "Jane Smith",
945
+ redirect: "/api/offerings/checkout/growth-ops-growth_yearly",
946
+ });
947
+ });
948
+
949
+ it("a stored lookup key the shown set does not show → exactly { kind: success } (#10823)", async () => {
950
+ answers({ completion: "checkout", checkoutOffering: "growth-ops-growth_once" });
951
+ shows({ status: "shown", shownSet: shownSet(["monthly", "yearly"], "growth-ops-growth") });
952
+
953
+ expect(await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS))).toStrictEqual({
954
+ kind: "success",
955
+ name: expect.any(String),
956
+ });
957
+ });
958
+
938
959
  it.each([
939
960
  ["a plan the shown set does not show", () => shows({ status: "shown", shownSet: shownSet(["monthly"], "growth") })],
940
961
  ["an unconnected site", () => shows({ status: "unconnected" })],
@@ -100,13 +100,15 @@
100
100
  * (#9142). The form hard-navigates there (`LeadCaptureForm`'s `redirect`),
101
101
  * because the route starts a session only for a top-level navigation and a
102
102
  * Server Action `redirect()` is fetched as RSC, which it sends to `/pricing`.
103
- * The stored value is a `{plan}`, so the key is the one that plan's
104
- * `/pricing` card links first: the first of `<plan>_monthly`,
105
- * `<plan>_yearly` and `<plan>_once` the shown set shows, read through
103
+ * A stored value `parseLookupKey` accepts (`growth-ops-growth_monthly`,
104
+ * #10823) is the key itself. Otherwise it is a `{plan}`, and the key is the
105
+ * one that plan's `/pricing` card links first: the first of `<plan>_monthly`,
106
+ * `<plan>_yearly` and `<plan>_once` the shown set shows. Either way the key
107
+ * must be in the shown set, read through
106
108
  * `offerings.shown()` within 5 seconds and shared per instance for 15, as
107
109
  * the buy route reads it. It runs only after the write resolved `ok`. The
108
110
  * value is re-checked with `flowCheckoutOfferingSchema` first; one that
109
- * fails, a plan the shown set does not show, or a read that fails or times
111
+ * fails, a key or plan the shown set does not show, or a read that fails or times
110
112
  * out answers `{ kind: "success" }`, as `lead` does.
111
113
  * A failed write never answers the buy route.
112
114
  */
@@ -118,6 +120,7 @@ import {
118
120
  flowCheckoutOfferingSchema,
119
121
  leadSubmitNextSchema,
120
122
  LOOKUP_KEY_PERIODS,
123
+ parseLookupKey,
121
124
  } from "@working-theory/validation";
122
125
  import { cookies, headers } from "next/headers";
123
126
  import { getLocale, getTranslations } from "next-intl/server";
@@ -247,8 +250,9 @@ function sharedShownSet(): Promise<unknown> {
247
250
  }
248
251
 
249
252
  /**
250
- * #8757 D11, #9171 — the Offerings buy route for the plan a `checkout` answer
251
- * names: its first shown key in `/pricing`'s card order; else `null`.
253
+ * #8757 D11, #9171 — the Offerings buy route for the offering a `checkout`
254
+ * answer names: the stored lookup key when it is one and shown (#10823), else
255
+ * the plan's first shown key in `/pricing`'s card order; else `null`.
252
256
  */
253
257
  async function checkoutHrefOf(next: unknown): Promise<string | null> {
254
258
  const parsed = leadSubmitNextSchema.safeParse(next);
@@ -256,9 +260,11 @@ async function checkoutHrefOf(next: unknown): Promise<string | null> {
256
260
  const plan = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
257
261
  if (!plan.success) return null;
258
262
  const shownSet = await sharedShownSet();
259
- const key = LOOKUP_KEY_PERIODS.map((period) => `${plan.data}_${period}`).find((k) =>
260
- isShownLookupKey(shownSet, k),
261
- );
263
+ const candidates =
264
+ parseLookupKey(plan.data) === null
265
+ ? LOOKUP_KEY_PERIODS.map((period) => `${plan.data}_${period}`)
266
+ : [plan.data];
267
+ const key = candidates.find((k) => isShownLookupKey(shownSet, k));
262
268
  return key === undefined ? null : offeringsCheckoutHref(key);
263
269
  }
264
270
 
@@ -42,14 +42,16 @@
42
42
  * a tier with no slug, or a flow with none, continues exactly as before. The
43
43
  * answers are read to score, never written into the href.
44
44
  *
45
- * Account link (#8771 D5): when the walk completes on an `invite` magnet (the
46
- * step response's `next`), the client asks `accountInviteLinkAction` for the
47
- * sign-in link with the email the visitor gave already filled in, then shows
48
- * "Track this in your account" on `FlowStepper`'s own finished panel — or,
49
- * for a flow with a `thankYou` block, hands the link to `/thanks/<slug>`
50
- * through `sessionStorage` (`ailk:account-invite`). The email never enters a
51
- * URL the walk navigates to (NFR1), and each link sits in a Session-Replay
52
- * `data-sentry-block` element, since the scrub cannot reach a DOM `href`.
45
+ * Sign-up landing (#10826 D6, after #8771 D5): when the walk completes on a
46
+ * `user` magnet (the step response's `next` is `{ completion: "invite" }`),
47
+ * the client asks `accountInviteLinkAction` for the sign-in link with the
48
+ * email the visitor gave already filled in, and navigates there at once —
49
+ * a full navigation (`window.location.assign`), because a hub sign-in path is
50
+ * not a Next route. A `null` link (no sign-in on this site) ends the walk as
51
+ * before. A response carrying a `deliverable` never navigates (#8848 D8): the
52
+ * sign-up landing then reaches the respondent through the verification
53
+ * email's confirm (D5). The email never enters a URL the walk navigates to
54
+ * other than that sign-in link itself.
53
55
  *
54
56
  * The standard magnet's parts (#8840 D9): the consent registry is the
55
57
  * platform default with the locale's `/terms` and `/privacy` links
@@ -116,6 +118,7 @@ import {
116
118
  resolveLocalizedString,
117
119
  resolveThankYouSlug,
118
120
  resolveTierThankYouSlug,
121
+ resultsPermalinkAnswers,
119
122
  selectFlowEndingOutcome,
120
123
  SITE_CONSENT_CONFIG_DEFAULTS,
121
124
  visitorNameOf,
@@ -150,9 +153,6 @@ function resolveSafeLocale(candidate: string): string {
150
153
  return ENABLED_LOCALES.includes(candidate) ? candidate : DEFAULT_LOCALE;
151
154
  }
152
155
 
153
- /** The `/thanks` hand-off key (#8771 D5) — read once by `AccountInviteLink`. */
154
- const ACCOUNT_INVITE_STORAGE_KEY = "ailk:account-invite";
155
-
156
156
  /**
157
157
  * #8771 D5 — the email the engine will land as the lead's: the first
158
158
  * `mapsTo: "email"` field of the submitted `field` step that holds a
@@ -190,24 +190,6 @@ function safeDeliverable(raw: unknown): FlowStepDeliverable | undefined {
190
190
  return parsed.success ? parsed.data : undefined;
191
191
  }
192
192
 
193
- /**
194
- * D7 (#4770) — every question `name` contributed by a `segmentation`-typed
195
- * step. This is the allowlist the encoded permalink is filtered to: a
196
- * `field`-typed step's values — mapped or unmapped, including the invisible
197
- * `aeo_score` step's own submission (D1) — never reach it.
198
- */
199
- function segmentationQuestionNames(config: FlowConfig): Set<string> {
200
- const names = new Set<string>();
201
- for (const step of config.steps) {
202
- if (step.type === "segmentation") {
203
- for (const question of step.questions) {
204
- names.add(question.name);
205
- }
206
- }
207
- }
208
- return names;
209
- }
210
-
211
193
  /**
212
194
  * #8840 D9 — the answers the ending is chosen from, assembled as the
213
195
  * permalink path assembles them: the visible steps' answers flattened, then
@@ -258,13 +240,10 @@ export function FlowStepperClient({
258
240
  // recommending completion can hand the walk's session to the results page
259
241
  // via `sessionStorage` (never the URL — the permalink stays answers-only).
260
242
  const lastSessionIdRef = useRef<string | undefined>(undefined);
261
- // #8771 D5 — the email the walk has landed, and the account link built from
262
- // it at completion on an `invite` magnet. The ref carries the link to
263
- // `handleCompleted`; the state draws it on the finished panel.
243
+ // #8771 D5 — the email the walk has landed. #10826 D6 — set once the walk
244
+ // has left for the sign-up landing, so no other ending races it.
264
245
  const emailRef = useRef<string | undefined>(undefined);
265
- const inviteHrefRef = useRef<string | null>(null);
266
- const [inviteHref, setInviteHref] = useState<string | null>(null);
267
- const tContact = useTranslations("contact");
246
+ const signingUpRef = useRef(false);
268
247
  // #8840 D9 — a config with an `ending` ends on it, never on `thankYou` or
269
248
  // the permalink; the chosen screen is drawn in the finished card.
270
249
  const endingWins = config.ending !== undefined;
@@ -285,8 +264,12 @@ export function FlowStepperClient({
285
264
  lastSessionIdRef.current = result.response.sessionId;
286
265
  emailRef.current = mappedEmailOf(config, payload) ?? emailRef.current;
287
266
  if (result.response.completed) {
288
- await resolveInviteLink(result.response);
289
- const delivered = showDeliverable((result.response as { deliverable?: unknown }).deliverable);
267
+ const raw = (result.response as { deliverable?: unknown }).deliverable;
268
+ // #8848 D8 — a deliverable (or a gated file's by-email line) is never
269
+ // lost to a navigation.
270
+ const hasDeliverable = raw !== undefined || config.deliverable !== undefined;
271
+ if (!hasDeliverable && (await goToSignUp(result.response))) return result;
272
+ const delivered = showDeliverable(raw);
290
273
  if (endingWins) showEnding(delivered);
291
274
  }
292
275
  } else if (result.kind === "step_conflict") {
@@ -296,23 +279,25 @@ export function FlowStepperClient({
296
279
  return result;
297
280
  }
298
281
 
299
- // #8771 D5 — on an `invite` completion with a landed email, ask the server
300
- // for the prefilled sign-in link. A rejected or null answer gives no link.
301
- async function resolveInviteLink(response: unknown): Promise<void> {
302
- inviteHrefRef.current = null;
303
- setInviteHref(null);
282
+ // #10826 D6 — on an `invite` completion (a `user` magnet) with a landed
283
+ // email, ask the server for the prefilled sign-in link and go there. True
284
+ // when the walk navigated; a rejected or null answer ends the walk as before.
285
+ async function goToSignUp(response: unknown): Promise<boolean> {
304
286
  const next = flowStepNextSchema.safeParse(
305
287
  (response as { next?: unknown }).next,
306
288
  );
307
289
  const email = emailRef.current;
308
- if (!next.success || email === undefined || isRecommending) return;
290
+ if (!next.success || email === undefined || isRecommending) return false;
291
+ let href: string | null;
309
292
  try {
310
- const href = await accountInviteLinkAction(email);
311
- inviteHrefRef.current = href;
312
- setInviteHref(href);
293
+ href = await accountInviteLinkAction(email);
313
294
  } catch {
314
- // No link — the walk still completes.
295
+ return false;
315
296
  }
297
+ if (href === null) return false;
298
+ signingUpRef.current = true;
299
+ window.location.assign(href);
300
+ return true;
316
301
  }
317
302
 
318
303
  // #8848 D8 — draw the response's deliverable (true when there is one), or,
@@ -385,14 +370,7 @@ export function FlowStepperClient({
385
370
  // redirect never runs, so the deliverable is never lost to a navigation.
386
371
  function showEnding(delivered = false): void {
387
372
  const answers = walkAnswers(config, answersRef.current);
388
- const segmentationNames = segmentationQuestionNames(config);
389
- const choices: Record<string, string | string[]> = {};
390
- for (const [name, value] of Object.entries(answers)) {
391
- if (segmentationNames.has(name) && (typeof value === "string" || Array.isArray(value))) {
392
- choices[name] = value as string | string[];
393
- }
394
- }
395
- const outcome = selectFlowEndingOutcome(config, choices);
373
+ const outcome = selectFlowEndingOutcome(config, resultsPermalinkAnswers(config, answers));
396
374
  if (outcome === undefined) return;
397
375
  if (outcome.kind === "redirect") {
398
376
  if (!delivered && flowSitePathSchema.safeParse(outcome.href).success) {
@@ -449,6 +427,7 @@ export function FlowStepperClient({
449
427
  segment?: string;
450
428
  aeoScore?: AeoScoreResult;
451
429
  }): void {
430
+ if (signingUpRef.current) return;
452
431
  // #10616 D4 — a scored tier's thank-you page wins over the permalink and
453
432
  // the `thankYou` block. Scored from the walk's answers as the server's
454
433
  // completion payload assembles them; no flags are passed (the server
@@ -481,15 +460,9 @@ export function FlowStepperClient({
481
460
  // below.
482
461
  Object.assign(flat, hiddenAnswers(config, answersRef.current, flags));
483
462
 
484
- // D7 — segmentation-only filter.
485
- const segmentationNames = segmentationQuestionNames(config);
486
- const answers: Record<string, string | string[]> = {};
487
- for (const [name, value] of Object.entries(flat)) {
488
- if (!segmentationNames.has(name)) continue;
489
- if (typeof value === "string" || Array.isArray(value)) {
490
- answers[name] = value as string | string[];
491
- }
492
- }
463
+ // D7 — segmentation-only filter (`resultsPermalinkAnswers`, the
464
+ // allowlist the Lead confirm's results landing shares, #10826 D5).
465
+ const answers = resultsPermalinkAnswers(config, flat);
493
466
 
494
467
  const payload = encodePermalink({
495
468
  version: PERMALINK_VERSION,
@@ -519,28 +492,8 @@ export function FlowStepperClient({
519
492
  }
520
493
 
521
494
  // 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.
495
+ // scored tier's slug (#10616 D4).
523
496
  function goToThanks(thankYouSlug: string): void {
524
- // #8771 D5 — hand the link to /thanks through the per-tab sessionStorage,
525
- // never the URL; with no link, clear an earlier walk's so it never shows
526
- // on this page. A storage that throws gives no link.
527
- if (typeof window !== "undefined") {
528
- try {
529
- if (inviteHrefRef.current !== null) {
530
- window.sessionStorage.setItem(
531
- ACCOUNT_INVITE_STORAGE_KEY,
532
- JSON.stringify({
533
- thanks: thankYouSlug,
534
- href: inviteHrefRef.current,
535
- }),
536
- );
537
- } else {
538
- window.sessionStorage.removeItem(ACCOUNT_INVITE_STORAGE_KEY);
539
- }
540
- } catch {
541
- // No hand-off.
542
- }
543
- }
544
497
  router.push(`/${resolveSafeLocale(locale)}/thanks/${thankYouSlug}`);
545
498
  }
546
499
 
@@ -584,25 +537,6 @@ export function FlowStepperClient({
584
537
  selectAllThatApply: t("selectAllThatApply"),
585
538
  };
586
539
 
587
- // #8771 D5 — the link goes INSIDE FlowStepper's own finished card (a flow
588
- // with a `thankYou` block navigates away instead, so it never shows there).
589
- // Session Replay records the DOM, so the link (it carries the email) is
590
- // blocked from it; `Link` does not forward an undeclared data-* attribute.
591
- const completedAction =
592
- inviteHref !== null && (endingWins || thankYou === undefined) && !isRecommending ? (
593
- <div data-sentry-block>
594
- <Button asChild variant="outline" className="mt-4 min-h-11">
595
- <Link
596
- href={inviteHref}
597
- variant="bare"
598
- data-testid="flow-account-invite"
599
- >
600
- {tContact("trackInAccount")}
601
- </Link>
602
- </Button>
603
- </div>
604
- ) : undefined;
605
-
606
540
  // #8848 D8 — the ending screen, then the deliverable; with no ending the
607
541
  // stepper's own finished copy heads the action.
608
542
  const completedContent =
@@ -629,7 +563,6 @@ export function FlowStepperClient({
629
563
  {...(!endingWins && (thankYou !== undefined || isRecommending || hasTierThankYou)
630
564
  ? { onCompleted: handleCompleted }
631
565
  : {})}
632
- {...(completedAction !== undefined ? { completedAction } : {})}
633
566
  // #8840 D9 — the locale's policy links, the input-step count, and the
634
567
  // page's hidden values, header and ending.
635
568
  consentConfig={{ ...SITE_CONSENT_CONFIG_DEFAULTS, policies: consentPoliciesFor(locale) }}