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.
- package/README.md +50 -8
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cutover.d.ts +6 -1
- package/dist/cutover.js +23 -6
- package/dist/export-content.d.ts +6 -3
- package/dist/export-content.js +79 -10
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.d.ts +42 -0
- package/dist/lib/import-mdx.js +584 -65
- package/dist/lib/render-comparison.d.ts +51 -0
- package/dist/lib/render-comparison.js +205 -6
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +1 -1
- package/dist/migrate-content.js +4 -2
- package/dist/programmatic.js +2 -8
- package/dist/seed-flows.d.ts +1 -1
- package/dist/seed-flows.js +9 -4
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +17 -0
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/apps/api/CLAUDE.md +7 -5
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
- package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
- package/templates/apps/web/auth.ts +22 -6
- package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
- package/templates/apps/web/lib/account-config.ts +52 -14
- package/templates/apps/web/lib/auth-email.ts +87 -5
- package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
- package/templates/apps/web/messages/en.json +26 -1
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/database/seed.ts +7 -7
- package/templates/package.json +2 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +48 -48
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
- 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
|
|
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
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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<{
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
251
|
-
* names:
|
|
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
|
|
260
|
-
|
|
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
|
-
*
|
|
46
|
-
* step response's `next`
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
262
|
-
//
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
// #
|
|
300
|
-
// for the prefilled sign-in link
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
311
|
-
inviteHrefRef.current = href;
|
|
312
|
-
setInviteHref(href);
|
|
293
|
+
href = await accountInviteLinkAction(email);
|
|
313
294
|
} catch {
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
486
|
-
const answers
|
|
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)
|
|
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) }}
|