create-ailk 0.32.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 +9 -3
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/export-content.d.ts +6 -3
- package/dist/export-content.js +79 -10
- package/dist/lib/import-mdx.d.ts +42 -0
- package/dist/lib/import-mdx.js +531 -45
- package/dist/seed-flows.d.ts +1 -1
- package/dist/seed-flows.js +9 -4
- package/dist/surfaces.js +1 -0
- package/package.json +3 -3
- package/templates/apps/api/CLAUDE.md +7 -5
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -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]/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]/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__/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/database/seed.ts +7 -7
- 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
package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* The respondent's own score on the results page (#10828).
|
|
4
|
+
*
|
|
5
|
+
* A scored submission renders the overall score — points out of the max and
|
|
6
|
+
* its Level name — then one labelled bar with its Level per dimension, in
|
|
7
|
+
* register order, then the cards. An unscored magnet renders the cards only.
|
|
8
|
+
* The score is the browser's own `evaluateWaitlistScoring` run over the
|
|
9
|
+
* decoded answers (#10825), as `recommend()` already is.
|
|
10
|
+
*
|
|
11
|
+
* Mocks mirror `ResultsClient.no-commerce.test.tsx`; the translator echoes
|
|
12
|
+
* its `n` value so the "Level {n}" fallback is observable.
|
|
13
|
+
*/
|
|
14
|
+
import { render, screen, within } from "@testing-library/react";
|
|
15
|
+
import type { FlowConfig } from "@working-theory/validation";
|
|
16
|
+
|
|
17
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
flows: { get: jest.fn() },
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// The boundary under test is ResultsClient's render, not the save action
|
|
23
|
+
// (save.actions.test.ts) — the narrow-scope mock ResultsClient's suites carry.
|
|
24
|
+
// eslint-disable-next-line no-restricted-syntax -- see comment above
|
|
25
|
+
jest.mock("../../../save.actions", () => ({
|
|
26
|
+
saveRecommendationAction: jest.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock("next-intl", () => ({
|
|
30
|
+
__esModule: true,
|
|
31
|
+
useTranslations:
|
|
32
|
+
() =>
|
|
33
|
+
(key: string, values?: { n?: number }): string =>
|
|
34
|
+
values?.n !== undefined ? `${key} ${values.n}` : key,
|
|
35
|
+
useMessages: () => ({}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { ResultsClient } from "../ResultsClient";
|
|
39
|
+
|
|
40
|
+
const SLUG = "ai-readiness";
|
|
41
|
+
const en = (s: string) => ({ en: s });
|
|
42
|
+
|
|
43
|
+
/** A three-option question, scored 0 / 2 / 4. */
|
|
44
|
+
function question(name: string) {
|
|
45
|
+
return {
|
|
46
|
+
key: `s_${name}`,
|
|
47
|
+
type: "segmentation" as const,
|
|
48
|
+
copy: { title: en(name) },
|
|
49
|
+
questions: [
|
|
50
|
+
{
|
|
51
|
+
name,
|
|
52
|
+
options: [
|
|
53
|
+
{ value: "low", label: en("Low") },
|
|
54
|
+
{ value: "mid", label: en("Mid") },
|
|
55
|
+
{ value: "high", label: en("High") },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const QUESTIONS = ["vision", "data", "skills"] as const;
|
|
63
|
+
|
|
64
|
+
type Scoring = NonNullable<FlowConfig["scoring"]>;
|
|
65
|
+
|
|
66
|
+
const SCORING = {
|
|
67
|
+
version: 1,
|
|
68
|
+
points: QUESTIONS.map((q) => ({
|
|
69
|
+
name: `p_${q}`,
|
|
70
|
+
question: q,
|
|
71
|
+
kind: "optionWeights" as const,
|
|
72
|
+
weights: { low: 0, mid: 2, high: 4 },
|
|
73
|
+
})),
|
|
74
|
+
scores: { total: { sum: QUESTIONS.map((q) => `p_${q}`) } },
|
|
75
|
+
tiers: [{ key: "all", when: { score: "total", gte: 0 } }],
|
|
76
|
+
default: "all",
|
|
77
|
+
// Register order is not alphabetical: the page keeps it.
|
|
78
|
+
dimensions: [
|
|
79
|
+
{ key: "strategy", name: "Strategy", questions: ["vision"] },
|
|
80
|
+
{ key: "data", name: "Data", questions: ["data"] },
|
|
81
|
+
{ key: "people", name: "People", questions: ["skills"] },
|
|
82
|
+
],
|
|
83
|
+
levels: { names: ["Exploring", "Practising", "Scaling", "Leading"] },
|
|
84
|
+
} satisfies Scoring;
|
|
85
|
+
|
|
86
|
+
function config(scoring: Scoring | undefined): FlowConfig {
|
|
87
|
+
return {
|
|
88
|
+
slug: SLUG,
|
|
89
|
+
defaultLocale: "en",
|
|
90
|
+
mode: "recommending",
|
|
91
|
+
steps: QUESTIONS.map(question),
|
|
92
|
+
...(scoring !== undefined ? { scoring } : {}),
|
|
93
|
+
offers: [
|
|
94
|
+
{
|
|
95
|
+
key: "workshop",
|
|
96
|
+
copy: { name: en("Workshop") },
|
|
97
|
+
price: { kind: "label", label: en("On request") },
|
|
98
|
+
rules: [{ when: { answer: "vision", in: ["low", "mid", "high"] }, reason: en("Start here.") }],
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
recommendation: { key: "result", copy: { title: en("Your AI readiness") } },
|
|
102
|
+
} as FlowConfig;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// vision low (0/4) · data mid (2/4) · skills high (4/4) → 6 of 12 = 50%.
|
|
106
|
+
const ANSWERS = { vision: "low", data: "mid", skills: "high" };
|
|
107
|
+
|
|
108
|
+
function renderResults(cfg: FlowConfig): void {
|
|
109
|
+
render(<ResultsClient config={cfg} locale="en" answers={ANSWERS} slug={SLUG} payload="v1-abc" />);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe("#10828 — a scored submission's results page", () => {
|
|
113
|
+
it("renders the overall score as points and its Level name", () => {
|
|
114
|
+
renderResults(config(SCORING));
|
|
115
|
+
const total = screen.getByTestId("flow-results-score-total");
|
|
116
|
+
expect(within(total).getByTestId("flow-results-score-total-points")).toHaveTextContent("6 / 12");
|
|
117
|
+
// 50% of all points is Level 3.
|
|
118
|
+
expect(within(total).getByTestId("flow-results-score-total-level")).toHaveTextContent("Scaling");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("renders one labelled bar with its Level per dimension, in register order", () => {
|
|
122
|
+
renderResults(config(SCORING));
|
|
123
|
+
const region = screen.getByTestId("flow-results-score");
|
|
124
|
+
const meters = within(region).getAllByRole("meter");
|
|
125
|
+
// The overall meter, then Strategy · Data · People as the register lists them.
|
|
126
|
+
expect(meters.map((m) => m.getAttribute("aria-label"))).toEqual(["score", "Strategy", "Data", "People"]);
|
|
127
|
+
expect(meters.map((m) => m.getAttribute("aria-valuetext"))).toEqual([
|
|
128
|
+
"6 / 12, Scaling",
|
|
129
|
+
"0 / 4, Exploring",
|
|
130
|
+
"2 / 4, Scaling",
|
|
131
|
+
"4 / 4, Leading",
|
|
132
|
+
]);
|
|
133
|
+
expect(screen.getByTestId("flow-results-score-dimension-data-level")).toHaveTextContent("Scaling");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("renders the score after the heading and before the cards", () => {
|
|
137
|
+
renderResults(config(SCORING));
|
|
138
|
+
const heading = screen.getByRole("heading", { name: "Your AI readiness" });
|
|
139
|
+
const score = screen.getByTestId("flow-results-score");
|
|
140
|
+
const card = screen.getByTestId("recommendation-card-workshop");
|
|
141
|
+
expect(heading.compareDocumentPosition(score) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
142
|
+
expect(score.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("names a Level 'Level {n}' when the register names none", () => {
|
|
146
|
+
const { levels: _levels, ...unnamed } = SCORING;
|
|
147
|
+
renderResults(config(unnamed));
|
|
148
|
+
expect(screen.getByTestId("flow-results-score-total-level")).toHaveTextContent("label 3");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("renders the overall score alone for a register with no dimensions", () => {
|
|
152
|
+
const { dimensions: _dimensions, ...flat } = SCORING;
|
|
153
|
+
renderResults(config(flat));
|
|
154
|
+
expect(within(screen.getByTestId("flow-results-score")).getAllByRole("meter")).toHaveLength(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("#10828 — an unscored magnet's results page", () => {
|
|
159
|
+
it("renders the cards only — no score, no bar", () => {
|
|
160
|
+
renderResults(config(undefined));
|
|
161
|
+
expect(screen.queryByTestId("flow-results-score")).toBeNull();
|
|
162
|
+
expect(screen.queryAllByRole("meter")).toHaveLength(0);
|
|
163
|
+
expect(screen.getByTestId("recommendation-card-workshop")).toBeInTheDocument();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("renders the cards only for a register that scores no points", () => {
|
|
167
|
+
renderResults(
|
|
168
|
+
config({
|
|
169
|
+
...SCORING,
|
|
170
|
+
points: [],
|
|
171
|
+
scores: {},
|
|
172
|
+
tiers: [{ key: "all", when: { answer: "vision", in: ["low", "mid", "high"] } }],
|
|
173
|
+
dimensions: [],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
expect(screen.queryByTestId("flow-results-score")).toBeNull();
|
|
177
|
+
expect(screen.getByTestId("recommendation-card-workshop")).toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* — step 1, never an error page — on ANY of: an invalid payload (FR6), a
|
|
12
12
|
* stale-version payload (FR6), a payload whose own `flowSlug` does not match
|
|
13
13
|
* this route's `[slug]` (D2/FR6), or a served flow that is not a
|
|
14
|
-
* recommending register (D8 — `mode !== "recommending"
|
|
14
|
+
* recommending register (D8 — `mode !== "recommending"`, or neither `offers`
|
|
15
|
+
* nor `recommendation.byResult` (#10827), checked
|
|
15
16
|
* against the field `recommend()` is about to be handed rather than
|
|
16
17
|
* inferring the compiler's five-keys-travel-together guarantee).
|
|
17
18
|
*
|
|
@@ -34,11 +35,22 @@
|
|
|
34
35
|
* key no longer shown, any failed read). The URL is built only from an
|
|
35
36
|
* allowlisted locale and a `parseLookupKey` plan. A flow with no such offer,
|
|
36
37
|
* or whose first fit carries none, renders as before with no read.
|
|
38
|
+
*
|
|
39
|
+
* #10827 — the by-result rows. `recommend()` returns the first matching
|
|
40
|
+
* row's `routeTo` (a legacy offer rule read as a row when the flow carries
|
|
41
|
+
* no `recommendation.byResult`; the landing above is that row's checkout
|
|
42
|
+
* kind for a `{plan}` key). A checkout row with a full lookup key goes
|
|
43
|
+
* straight to the Offerings buy route while the key is shown; an offering
|
|
44
|
+
* row — or a checkout row whose offering is not shown, a draft — renders
|
|
45
|
+
* the results page with one card named from the offering, priced only
|
|
46
|
+
* while the offering is shown.
|
|
37
47
|
*/
|
|
38
48
|
import { flows, offerings } from "@working-theory/api-client";
|
|
39
49
|
import {
|
|
40
50
|
decodePermalink,
|
|
41
51
|
type FlowConfig,
|
|
52
|
+
type FlowResultRouteTo,
|
|
53
|
+
flowRecommendRegister,
|
|
42
54
|
parseLookupKey,
|
|
43
55
|
recommend,
|
|
44
56
|
resolveLocalizedString,
|
|
@@ -51,8 +63,9 @@ import { redirect } from "next/navigation";
|
|
|
51
63
|
import type { ReactElement } from "react";
|
|
52
64
|
|
|
53
65
|
import { browserApiOrigin } from "../../../../../../lib/api-origin.generated";
|
|
66
|
+
import { offeringsCheckoutHref } from "../../../../../../lib/offerings-pricing";
|
|
54
67
|
|
|
55
|
-
import type { ResultsReturnState } from "./ResultsClient";
|
|
68
|
+
import type { ResultsOfferingCard, ResultsReturnState } from "./ResultsClient";
|
|
56
69
|
import { ResultsClient } from "./ResultsClient";
|
|
57
70
|
|
|
58
71
|
export const dynamic = "force-dynamic";
|
|
@@ -142,58 +155,109 @@ function isOfferingsOffer(offer: unknown): boolean {
|
|
|
142
155
|
);
|
|
143
156
|
}
|
|
144
157
|
|
|
158
|
+
/** #10827 — a shown set's prices of `offeringKey`: its own key, else its plan's. */
|
|
159
|
+
function shownPricesOf(shown: WorkspaceShownSetWire | null, offeringKey: string) {
|
|
160
|
+
const exact = parseLookupKey(offeringKey);
|
|
161
|
+
const plan = exact?.plan ?? offeringKey;
|
|
162
|
+
const product = shown?.offerings.find((o) => o.prices.some((p) => p.plan === plan));
|
|
163
|
+
const prices = product?.prices ?? [];
|
|
164
|
+
return {
|
|
165
|
+
plan,
|
|
166
|
+
product,
|
|
167
|
+
price:
|
|
168
|
+
exact !== null
|
|
169
|
+
? prices.find((p) => p.lookupKey === offeringKey)
|
|
170
|
+
: (prices.find((p) => p.period === "monthly") ?? prices[0]),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type ResultLanding =
|
|
175
|
+
| { kind: "redirect"; url: string }
|
|
176
|
+
| { kind: "render"; card?: ResultsOfferingCard };
|
|
177
|
+
|
|
145
178
|
/**
|
|
146
|
-
* #9071 D3 — where a completed
|
|
147
|
-
*
|
|
148
|
-
*
|
|
179
|
+
* #9071 D3 / #10827 — where a completed walk lands: a redirect (the
|
|
180
|
+
* `/pricing?plan=` landing, or checkout), or the results page with the
|
|
181
|
+
* matched row's offering card. The served config is not re-parsed here, so
|
|
182
|
+
* every read of it is shape-checked and any throw renders as before.
|
|
149
183
|
*/
|
|
150
|
-
async function
|
|
184
|
+
async function resultLanding(
|
|
151
185
|
config: FlowConfig,
|
|
152
186
|
answers: WaitlistAnswers,
|
|
153
187
|
locale: string,
|
|
154
|
-
): Promise<
|
|
188
|
+
): Promise<ResultLanding> {
|
|
155
189
|
try {
|
|
190
|
+
const byResult: unknown = config.recommendation?.byResult;
|
|
191
|
+
const authored = Array.isArray(byResult);
|
|
156
192
|
const offers: unknown = config.offers;
|
|
157
|
-
if (!Array.isArray(offers)
|
|
193
|
+
if (!authored && !(Array.isArray(offers) && offers.some(isOfferingsOffer))) {
|
|
194
|
+
return { kind: "render" };
|
|
195
|
+
}
|
|
158
196
|
|
|
159
|
-
let
|
|
197
|
+
let routeTo: FlowResultRouteTo | undefined;
|
|
160
198
|
try {
|
|
161
|
-
|
|
162
|
-
{
|
|
163
|
-
key: config.experiment?.key ?? config.slug,
|
|
164
|
-
offers: config.offers,
|
|
165
|
-
pairings: config.pairings,
|
|
166
|
-
rateTable: config.rateTable,
|
|
167
|
-
scoring: config.scoring,
|
|
168
|
-
},
|
|
169
|
-
answers,
|
|
170
|
-
).fits[0]?.offerKey;
|
|
199
|
+
routeTo = recommend(flowRecommendRegister(config), answers).routeTo;
|
|
171
200
|
} catch {
|
|
172
|
-
|
|
201
|
+
routeTo = undefined;
|
|
173
202
|
}
|
|
174
|
-
const fit: unknown =
|
|
175
|
-
fitKey === undefined
|
|
176
|
-
? undefined
|
|
177
|
-
: offers.find(
|
|
178
|
-
(o: unknown) =>
|
|
179
|
-
typeof o === "object" && o !== null && (o as { key?: unknown }).key === fitKey,
|
|
180
|
-
);
|
|
181
|
-
if (fit !== undefined && !isOfferingsOffer(fit)) return null;
|
|
182
203
|
|
|
183
204
|
const safeLocale = ENABLED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
|
|
184
205
|
const base = `/${safeLocale}/pricing`;
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
206
|
+
if (routeTo === undefined || routeTo.kind === "none") {
|
|
207
|
+
// An Offerings recommender with no fit lands on /pricing with no badge.
|
|
208
|
+
return authored ? { kind: "render" } : { kind: "redirect", url: base };
|
|
209
|
+
}
|
|
210
|
+
if (!authored && routeTo.kind === "offering") return { kind: "render" };
|
|
189
211
|
|
|
212
|
+
const { offeringKey } = routeTo;
|
|
190
213
|
const shown = await readCurrentShownSet();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
const { plan, product, price } = shownPricesOf(shown, offeringKey);
|
|
215
|
+
// Built from the shown set's own grammar-checked Price, never the config key.
|
|
216
|
+
let checkoutHref: string | null = null;
|
|
217
|
+
if (routeTo.kind === "checkout") {
|
|
218
|
+
// A `{plan}` key lands on /pricing?plan=. A full key goes straight to
|
|
219
|
+
// the buy route — from the browser (`ResultsClient`), because the walk
|
|
220
|
+
// arrives by a client-side navigation and the buy route refuses
|
|
221
|
+
// anything but a top-level document load.
|
|
222
|
+
if (price !== undefined && parseLookupKey(offeringKey) === null) {
|
|
223
|
+
return { kind: "redirect", url: `${base}?plan=${price.plan}` };
|
|
224
|
+
}
|
|
225
|
+
if (price !== undefined) checkoutHref = offeringsCheckoutHref(price.lookupKey);
|
|
226
|
+
if (checkoutHref === null && !authored) return { kind: "redirect", url: base };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// An offering's card — or a checkout row's offering not shown (a draft).
|
|
230
|
+
const legacy = Array.isArray(offers)
|
|
231
|
+
? (offers as unknown[]).find(
|
|
232
|
+
(o): o is { copy: { name: Parameters<typeof resolveLocalizedString>[0] } } =>
|
|
233
|
+
typeof o === "object" &&
|
|
234
|
+
o !== null &&
|
|
235
|
+
(o as { key?: unknown }).key === offeringKey &&
|
|
236
|
+
(o as { copy?: { name?: unknown } }).copy?.name !== undefined,
|
|
237
|
+
)
|
|
238
|
+
: undefined;
|
|
239
|
+
// `/pricing`'s last guard before markup: no format or control characters.
|
|
240
|
+
const title =
|
|
241
|
+
product?.name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/gu, "") ??
|
|
242
|
+
(legacy
|
|
243
|
+
? resolveLocalizedString(legacy.copy.name, locale, config.defaultLocale)
|
|
244
|
+
: plan.charAt(0).toUpperCase() + plan.slice(1).replace(/-/g, " "));
|
|
245
|
+
return {
|
|
246
|
+
kind: "render",
|
|
247
|
+
card: {
|
|
248
|
+
offeringKey,
|
|
249
|
+
title,
|
|
250
|
+
...(price !== undefined
|
|
251
|
+
? {
|
|
252
|
+
price: { amount: price.amount, currency: price.currency, period: price.period },
|
|
253
|
+
href: checkoutHref ?? `${base}?plan=${price.plan}`,
|
|
254
|
+
}
|
|
255
|
+
: {}),
|
|
256
|
+
...(checkoutHref !== null ? { checkoutOnLoad: true } : {}),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
195
259
|
} catch {
|
|
196
|
-
return
|
|
260
|
+
return { kind: "render" };
|
|
197
261
|
}
|
|
198
262
|
}
|
|
199
263
|
|
|
@@ -236,16 +300,19 @@ export default async function Page({
|
|
|
236
300
|
if (!res.ok) {
|
|
237
301
|
redirect(`/${locale}/flows/${slug}`);
|
|
238
302
|
}
|
|
239
|
-
if (
|
|
303
|
+
if (
|
|
304
|
+
res.body.mode !== "recommending" ||
|
|
305
|
+
(res.body.offers === undefined && res.body.recommendation?.byResult === undefined)
|
|
306
|
+
) {
|
|
240
307
|
redirect(`/${locale}/flows/${slug}`);
|
|
241
308
|
}
|
|
242
309
|
|
|
243
|
-
const landing = await
|
|
310
|
+
const landing = await resultLanding(
|
|
244
311
|
res.body,
|
|
245
312
|
decoded.value.answers,
|
|
246
313
|
locale,
|
|
247
314
|
);
|
|
248
|
-
if (landing
|
|
315
|
+
if (landing.kind === "redirect") redirect(landing.url);
|
|
249
316
|
|
|
250
317
|
// FR7 — read AFTER every redirect guard above, so a return param never
|
|
251
318
|
// changes which guard fires or where it sends the visitor.
|
|
@@ -258,6 +325,7 @@ export default async function Page({
|
|
|
258
325
|
answers={decoded.value.answers}
|
|
259
326
|
slug={slug}
|
|
260
327
|
payload={payload}
|
|
328
|
+
{...(landing.card !== undefined ? { offeringCard: landing.card } : {})}
|
|
261
329
|
{...(returnState !== undefined ? { returnState } : {})}
|
|
262
330
|
/>
|
|
263
331
|
);
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* `/[locale]/flows/[slug]/verify` — a magnet Lead's confirm page (#10826,
|
|
4
|
+
* spec docs/specs/10826-the-four-conversions-as-completion-spec.md D5).
|
|
5
|
+
* `noindex`.
|
|
6
|
+
*
|
|
7
|
+
* Three states, in this precedence:
|
|
8
|
+
* - `?lead=<id>&verify=<nonce>` → a confirm card holding ONE button. The GET
|
|
9
|
+
* writes nothing and calls no API: the button posts to
|
|
10
|
+
* `confirmLeadEmailAction`, bound to the slug, the Lead id,
|
|
11
|
+
* the nonce and the locale (no hidden input carries them),
|
|
12
|
+
* so a mail scanner or a link preview that fetches the
|
|
13
|
+
* link verifies nothing — only a person's click does.
|
|
14
|
+
* - `?confirmed=1` → the verified card, with a link back to the magnet.
|
|
15
|
+
* - `?error=…`, or any other query → the expired-or-invalid card.
|
|
16
|
+
*
|
|
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. `lead` and `verify`
|
|
19
|
+
* are in both telemetry scrub lists.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { localeHref } from "@working-theory/ui";
|
|
23
|
+
import { AuthCard, type AuthCardLabels } from "@working-theory/ui/blocks";
|
|
24
|
+
import { Button, Text } from "@working-theory/ui/primitives";
|
|
25
|
+
import { flowSlugSchema } from "@working-theory/validation";
|
|
26
|
+
import type { Metadata } from "next";
|
|
27
|
+
import { notFound } from "next/navigation";
|
|
28
|
+
import { getTranslations } from "next-intl/server";
|
|
29
|
+
import type { ReactElement } from "react";
|
|
30
|
+
|
|
31
|
+
import { siteLogo } from "../../../../../lib/site-logo";
|
|
32
|
+
import { confirmLeadEmailAction } from "../verify.actions";
|
|
33
|
+
|
|
34
|
+
interface Props {
|
|
35
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
36
|
+
searchParams?: Promise<{
|
|
37
|
+
lead?: string | string[];
|
|
38
|
+
verify?: string | string[];
|
|
39
|
+
confirmed?: string | string[];
|
|
40
|
+
error?: string | string[];
|
|
41
|
+
}>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LEAD_ID_RE = /^lead_[0-9a-f-]{36}$/;
|
|
45
|
+
|
|
46
|
+
function firstValue(raw: string | string[] | undefined): string {
|
|
47
|
+
if (Array.isArray(raw)) return raw[0] ?? "";
|
|
48
|
+
return raw ?? "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
52
|
+
const { locale } = await params;
|
|
53
|
+
const t = await getTranslations({ locale, namespace: "flowVerify" });
|
|
54
|
+
return { title: t("title"), robots: { index: false, follow: false } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default async function FlowVerifyPage({
|
|
58
|
+
params,
|
|
59
|
+
searchParams,
|
|
60
|
+
}: Props): Promise<ReactElement> {
|
|
61
|
+
const { locale, slug } = await params;
|
|
62
|
+
if (!flowSlugSchema.safeParse(slug).success) notFound();
|
|
63
|
+
const t = await getTranslations({ locale, namespace: "flowVerify" });
|
|
64
|
+
const magnetHref = localeHref(locale, `/flows/${slug}`);
|
|
65
|
+
|
|
66
|
+
const query = searchParams ? await searchParams : {};
|
|
67
|
+
const leadId = firstValue(query.lead);
|
|
68
|
+
const nonce = firstValue(query.verify);
|
|
69
|
+
|
|
70
|
+
if (LEAD_ID_RE.test(leadId) && nonce.length >= 1 && nonce.length <= 256) {
|
|
71
|
+
const labels: AuthCardLabels = {
|
|
72
|
+
sentHeading: t("confirmHeading"),
|
|
73
|
+
sentSubhead: t("confirmSubhead"),
|
|
74
|
+
backToSignIn: t("back"),
|
|
75
|
+
};
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col gap-4" data-testid="flow-verify-page">
|
|
78
|
+
<div data-testid="flow-verify-confirm">
|
|
79
|
+
<AuthCard
|
|
80
|
+
logo={siteLogo()}
|
|
81
|
+
intent="sent"
|
|
82
|
+
signInHref={magnetHref}
|
|
83
|
+
labels={labels}
|
|
84
|
+
body={
|
|
85
|
+
<form
|
|
86
|
+
action={confirmLeadEmailAction.bind(
|
|
87
|
+
null,
|
|
88
|
+
slug,
|
|
89
|
+
leadId,
|
|
90
|
+
nonce,
|
|
91
|
+
locale,
|
|
92
|
+
)}
|
|
93
|
+
className="flex flex-col gap-5 text-center"
|
|
94
|
+
>
|
|
95
|
+
<Text variant="body-sm" tone="muted">
|
|
96
|
+
{t("confirmBody")}
|
|
97
|
+
</Text>
|
|
98
|
+
<Button
|
|
99
|
+
type="submit"
|
|
100
|
+
size="big"
|
|
101
|
+
className="min-h-11 w-full"
|
|
102
|
+
data-testid="flow-verify-confirm-button"
|
|
103
|
+
>
|
|
104
|
+
{t("confirmButton")}
|
|
105
|
+
</Button>
|
|
106
|
+
</form>
|
|
107
|
+
}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (firstValue(query.confirmed) === "1") {
|
|
115
|
+
const labels: AuthCardLabels = {
|
|
116
|
+
sentHeading: t("confirmedHeading"),
|
|
117
|
+
sentSubhead: t("confirmedSubhead"),
|
|
118
|
+
sentBody: t("confirmedBody"),
|
|
119
|
+
backToSignIn: t("back"),
|
|
120
|
+
};
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex flex-col gap-4" data-testid="flow-verify-page">
|
|
123
|
+
<div data-testid="flow-verify-confirmed">
|
|
124
|
+
<AuthCard
|
|
125
|
+
logo={siteLogo()}
|
|
126
|
+
intent="sent"
|
|
127
|
+
signInHref={magnetHref}
|
|
128
|
+
labels={labels}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const labels: AuthCardLabels = {
|
|
136
|
+
invitationHeading: t("errorHeading"),
|
|
137
|
+
invitationSubhead: t("errorSubhead"),
|
|
138
|
+
invitationBody: t("errorBody"),
|
|
139
|
+
backToSignIn: t("back"),
|
|
140
|
+
};
|
|
141
|
+
return (
|
|
142
|
+
<div className="flex flex-col gap-4" data-testid="flow-verify-page">
|
|
143
|
+
<div data-testid="flow-verify-error">
|
|
144
|
+
<AuthCard
|
|
145
|
+
logo={siteLogo()}
|
|
146
|
+
intent="invitation"
|
|
147
|
+
signInHref={magnetHref}
|
|
148
|
+
labels={labels}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|