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.
Files changed (46) hide show
  1. package/README.md +9 -3
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/export-content.d.ts +6 -3
  5. package/dist/export-content.js +79 -10
  6. package/dist/lib/import-mdx.d.ts +42 -0
  7. package/dist/lib/import-mdx.js +531 -45
  8. package/dist/seed-flows.d.ts +1 -1
  9. package/dist/seed-flows.js +9 -4
  10. package/dist/surfaces.js +1 -0
  11. package/package.json +3 -3
  12. package/templates/apps/api/CLAUDE.md +7 -5
  13. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  14. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  15. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  16. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  17. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  18. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  19. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  20. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  21. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  22. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  23. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  24. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  25. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  26. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  27. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  28. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  29. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  30. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  31. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  32. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  33. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  34. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  35. package/templates/apps/web/auth.ts +22 -6
  36. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  37. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  38. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  39. package/templates/apps/web/lib/account-config.ts +52 -14
  40. package/templates/apps/web/lib/auth-email.ts +87 -5
  41. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  42. package/templates/apps/web/messages/en.json +26 -1
  43. package/templates/database/seed.ts +7 -7
  44. package/versions.json +48 -48
  45. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  46. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -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" || !offers`, checked
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 Offerings recommender lands, or `null` to
147
- * render the results page as before. The served config is not re-parsed
148
- * here, so every read of it is shape-checked and any throw is `null`.
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 recommenderLanding(
184
+ async function resultLanding(
151
185
  config: FlowConfig,
152
186
  answers: WaitlistAnswers,
153
187
  locale: string,
154
- ): Promise<string | null> {
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) || !offers.some(isOfferingsOffer)) return null;
193
+ if (!authored && !(Array.isArray(offers) && offers.some(isOfferingsOffer))) {
194
+ return { kind: "render" };
195
+ }
158
196
 
159
- let fitKey: string | undefined;
197
+ let routeTo: FlowResultRouteTo | undefined;
160
198
  try {
161
- fitKey = recommend(
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
- fitKey = undefined;
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 (fit === undefined) return base;
186
- const lookupKey = (fit as { lookupKey: string }).lookupKey;
187
- const parsed = parseLookupKey(lookupKey);
188
- if (parsed === null) return base;
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 isShown =
192
- shown?.offerings.some((o) => o.prices.some((p) => p.lookupKey === lookupKey)) ??
193
- false;
194
- return isShown ? `${base}?plan=${parsed.plan}` : base;
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 null;
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 (res.body.mode !== "recommending" || res.body.offers === undefined) {
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 recommenderLanding(
310
+ const landing = await resultLanding(
244
311
  res.body,
245
312
  decoded.value.answers,
246
313
  locale,
247
314
  );
248
- if (landing !== null) redirect(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
+ }