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
@@ -13,7 +13,8 @@
13
13
  * Book-a-Call rail, §7.3); active region = the fits as
14
14
  * `RecommendationCard` (first fit `emphasis: "recommended"`, each with a
15
15
  * per-offer terminal — D2) + `PairingCard` when one matched; bar
16
- * `leading` = "Email me this plan" (D5).
16
+ * `leading` = "Email me this plan" (D5), absent when the served
17
+ * conversion is `none` (#10826).
17
18
  * - **checkout** — Get Started performs DESIGN §7.6's panel FLIP: the
18
19
  * email step takes the frame's first (side) slot — "fields left" — and
19
20
  * the configurable price card (#4865, D3) takes the second (active)
@@ -25,6 +26,14 @@
25
26
  * - **save** — the same email step with the save framing; the side panel
26
27
  * stays the safe-default card (FR4); success renders the saved state.
27
28
  *
29
+ * #10828 — a scored register's results open with the respondent's own
30
+ * score: the overall points out of the max and its Level name, then one
31
+ * `ScoreBar` per dimension in register order, then the cards. The score is
32
+ * `evaluateWaitlistScoring` over the decoded answers, run here as
33
+ * `recommend()` is; a Level is named by the register's `levels.names`, else
34
+ * "Level {n}". An unscored register (no scoring, or no points to score)
35
+ * renders the cards only.
36
+ *
28
37
  * The three frame regions keep their DOM identity across every transition
29
38
  * (#4806 FR7 — the frame is rendered once; only its slots change).
30
39
  *
@@ -54,6 +63,7 @@ import {
54
63
  Heading,
55
64
  PairingCard,
56
65
  RecommendationCard,
66
+ ScoreBar,
57
67
  Text,
58
68
  } from "@working-theory/ui";
59
69
  import {
@@ -61,16 +71,24 @@ import {
61
71
  type FlowSidePanelView,
62
72
  } from "@working-theory/ui-signal-collectors";
63
73
  import type {
74
+ FlowCompletion,
64
75
  FlowConfig,
65
76
  FlowSaveInput,
77
+ LookupKeyPeriod,
66
78
  RecommendRegister,
67
79
  WaitlistBillingPeriod,
68
80
  } from "@working-theory/validation";
69
- import { recommend, resolveLocalizedString } from "@working-theory/validation";
81
+ import {
82
+ evaluateWaitlistScoring,
83
+ flowRecommendRegister,
84
+ recommend,
85
+ resolveLocalizedString,
86
+ } from "@working-theory/validation";
70
87
  import { useTranslations } from "next-intl";
71
88
  import type { ReactElement, ReactNode } from "react";
72
89
  import { useContext, useEffect, useMemo, useState } from "react";
73
90
 
91
+ import { formatMinorUnits } from "../../../../../../lib/offerings-pricing";
74
92
  import { saveRecommendationAction } from "../../save.actions";
75
93
 
76
94
  import type {
@@ -82,8 +100,27 @@ import { ResultsEmailStep } from "./ResultsEmailStep";
82
100
 
83
101
  export type ResultsReturnState = "success" | "cancelled";
84
102
 
103
+ /**
104
+ * #10827 — the matched by-result row's offering, resolved by the page from
105
+ * the shown set: named from the offering; `price` and `href` (its
106
+ * `/pricing?plan=` card) only while it is shown — a draft has neither.
107
+ */
108
+ export interface ResultsOfferingCard {
109
+ offeringKey: string;
110
+ title: string;
111
+ /** Stripe minor units, from the offering's own Price. */
112
+ price?: { amount: number; currency: string; period: LookupKeyPeriod };
113
+ href?: string;
114
+ /**
115
+ * A checkout row's shown offering: on load the browser goes to `href`, the
116
+ * Offerings buy route, as a top-level navigation; the card is the fallback.
117
+ */
118
+ checkoutOnLoad?: true;
119
+ }
120
+
85
121
  interface Props {
86
- config: FlowConfig;
122
+ /** The served config; `conversion` is the magnet's (#10826), absent from an older API. */
123
+ config: FlowConfig & { conversion?: FlowCompletion };
87
124
  locale: string;
88
125
  answers: Record<string, string | string[]>;
89
126
  slug: string;
@@ -91,6 +128,8 @@ interface Props {
91
128
  payload: string;
92
129
  /** FR7/D6 — the `?checkout=` return micro-state, validated by the page. */
93
130
  returnState?: ResultsReturnState;
131
+ /** #10827 — the matched by-result row's offering card. */
132
+ offeringCard?: ResultsOfferingCard;
94
133
  }
95
134
 
96
135
  type View = "results" | "checkout" | "save";
@@ -147,22 +186,34 @@ export function ResultsClient({
147
186
  slug,
148
187
  payload,
149
188
  returnState,
189
+ offeringCard,
150
190
  }: Props): ReactElement {
151
191
  const t = useTranslations("flow");
192
+ const tOfferings = useTranslations("pricingOfferings");
193
+ // #10828 — the score's two words, from the catalog's existing entries.
194
+ const tScore = useTranslations("account.results");
195
+ const tLevel = useTranslations("waitlistDashboard.level");
152
196
  const checkout = useContext(ResultsCheckoutContext);
153
197
  const { defaultLocale, offers = [], recommendation } = config;
154
198
 
155
- const register = useMemo<RecommendRegister>(
156
- () => ({
157
- key: config.experiment?.key ?? config.slug,
158
- offers: config.offers,
159
- pairings: config.pairings,
160
- rateTable: config.rateTable,
161
- scoring: config.scoring,
162
- }),
163
- [config],
164
- );
199
+ const register = useMemo<RecommendRegister>(() => flowRecommendRegister(config), [config]);
165
200
  const result = useMemo(() => recommend(register, answers), [register, answers]);
201
+ // #10828 — no points to score (max 0) is an unscored register. The
202
+ // evaluator returns `dimensions` 1:1 with, and in the order of, the
203
+ // register's own, so each takes its name by index.
204
+ const score = useMemo(() => {
205
+ const scoring = config.scoring;
206
+ if (!scoring) return undefined;
207
+ const scored = evaluateWaitlistScoring(scoring, answers);
208
+ if (scored.total.max <= 0) return undefined;
209
+ return {
210
+ total: scored.total,
211
+ dimensions: scored.dimensions.map((dimension, i) => ({
212
+ ...dimension,
213
+ name: scoring.dimensions?.[i]?.name ?? dimension.key,
214
+ })),
215
+ };
216
+ }, [config.scoring, answers]);
166
217
  const currency = config.rateTable?.currency;
167
218
 
168
219
  const [view, setView] = useState<View>("results");
@@ -200,6 +251,34 @@ export function ResultsClient({
200
251
  "one-time": t("priceCard.periodLabels.one-time"),
201
252
  };
202
253
 
254
+ /** #10827 — an offering's Price as `/pricing` words it. */
255
+ function offeringPriceLabel(price: NonNullable<ResultsOfferingCard["price"]>): string {
256
+ const amount = formatMinorUnits(price.amount, price.currency, locale);
257
+ if (price.period === "monthly") return tOfferings("monthly", { amount });
258
+ if (price.period === "yearly") return tOfferings("annual", { amount });
259
+ return tOfferings("oneTime", { amount });
260
+ }
261
+
262
+ const offeringHref = offeringCard?.href;
263
+ const checkoutOnLoad = offeringCard?.checkoutOnLoad === true;
264
+
265
+ // #10827 — straight to checkout: the buy route sells only on a top-level
266
+ // document load, which a server redirect inside the walk's client-side
267
+ // navigation is not. Only when this tab just walked the flow (the session
268
+ // entry `FlowStepperClient` writes before it navigates here) — a cold load
269
+ // of a shared or cross-site permalink, and a `?checkout=` return, show the
270
+ // card and wait for the click. `replace`, so Back from Stripe skips this page.
271
+ useEffect(() => {
272
+ if (!checkoutOnLoad || offeringHref === undefined || returnState !== undefined) return;
273
+ let walked = false;
274
+ try {
275
+ walked = Boolean(window.sessionStorage.getItem(flowSessionStorageKey(slug)));
276
+ } catch {
277
+ // a privacy mode: the click alone
278
+ }
279
+ if (walked) window.location.replace(offeringHref);
280
+ }, [checkoutOnLoad, offeringHref, returnState, slug]);
281
+
203
282
  const topFit = result.fits[0];
204
283
  const topOffer = topFit
205
284
  ? offers.find((offer) => offer.key === topFit.offerKey)
@@ -369,6 +448,8 @@ export function ResultsClient({
369
448
  if (view === "results") {
370
449
  const heading = recommendation ? resolve(recommendation.copy.title) : undefined;
371
450
  const body = recommendation?.copy.body ? resolve(recommendation.copy.body) : undefined;
451
+ const levelName = (level: number): string =>
452
+ config.scoring?.levels?.names[level - 1] ?? tLevel("label", { n: level });
372
453
 
373
454
  active = (
374
455
  <div data-testid="flow-results">
@@ -389,10 +470,71 @@ export function ResultsClient({
389
470
  {body}
390
471
  </Text>
391
472
  ) : null}
473
+ {score ? (
474
+ <div data-testid="flow-results-score" className="mt-block-loose flex flex-col gap-6">
475
+ <ScoreBar
476
+ size="lg"
477
+ label={tScore("score")}
478
+ points={score.total.points}
479
+ max={score.total.max}
480
+ pct={score.total.pct}
481
+ levelName={levelName(score.total.level)}
482
+ locale={locale}
483
+ data-testid="flow-results-score-total"
484
+ />
485
+ {score.dimensions.length > 0 ? (
486
+ <div className="flex flex-col gap-4">
487
+ {score.dimensions.map((dimension) => (
488
+ <ScoreBar
489
+ key={dimension.key}
490
+ label={dimension.name}
491
+ points={dimension.points}
492
+ max={dimension.max}
493
+ pct={dimension.pct}
494
+ levelName={levelName(dimension.level)}
495
+ locale={locale}
496
+ data-testid={`flow-results-score-dimension-${dimension.key}`}
497
+ />
498
+ ))}
499
+ </div>
500
+ ) : null}
501
+ </div>
502
+ ) : null}
392
503
  <div className="mt-block-loose flex flex-col gap-4">
504
+ {offeringCard ? (
505
+ <RecommendationCard
506
+ data-testid={`recommendation-card-${offeringCard.offeringKey}`}
507
+ title={offeringCard.title}
508
+ emphasis="recommended"
509
+ recommendedLabel={t("priceCard.eyebrow")}
510
+ {...(offeringCard.price
511
+ ? {
512
+ price: {
513
+ kind: "label",
514
+ label: { [locale]: offeringPriceLabel(offeringCard.price) },
515
+ currency: offeringCard.price.currency,
516
+ locale,
517
+ periodLabels,
518
+ fromPrefix: t("price.fromPrefix"),
519
+ },
520
+ }
521
+ : {})}
522
+ {...(offeringHref !== undefined
523
+ ? {
524
+ terminal: {
525
+ kind: "getStarted",
526
+ label: t("getStarted"),
527
+ onSelect: () => window.location.assign(offeringHref),
528
+ },
529
+ }
530
+ : {})}
531
+ />
532
+ ) : null}
393
533
  {result.fits.map((fit, index) => {
394
534
  const offer = offers.find((o) => o.key === fit.offerKey);
395
535
  if (!offer) return null;
536
+ // #10827 — one recommended card: a by-result card takes the badge.
537
+ const recommendedFit = index === 0 && !offeringCard;
396
538
  // Get Started only where commerce is on (#6786) AND the offer is quotable (D2).
397
539
  const quotable =
398
540
  checkout !== undefined &&
@@ -427,8 +569,8 @@ export function ResultsClient({
427
569
  {...(offer.copy.description
428
570
  ? { description: resolve(offer.copy.description) }
429
571
  : {})}
430
- emphasis={index === 0 ? "recommended" : "default"}
431
- {...(index === 0 ? { recommendedLabel: t("priceCard.eyebrow") } : {})}
572
+ emphasis={recommendedFit ? "recommended" : "default"}
573
+ {...(recommendedFit ? { recommendedLabel: t("priceCard.eyebrow") } : {})}
432
574
  {...(price !== undefined ? { price } : {})}
433
575
  terminal={terminal}
434
576
  whyThis={{ label: t("whyThis"), content: resolve(fit.reason) }}
@@ -448,11 +590,13 @@ export function ResultsClient({
448
590
  </div>
449
591
  </div>
450
592
  );
451
- leading = (
452
- <Button type="button" variant="ghost" size="big" onClick={openSave}>
453
- {t("emailMePlan")}
454
- </Button>
455
- );
593
+ // #10826 — a magnet that converts no one (`none`) asks for no email.
594
+ leading =
595
+ config.conversion === "none" ? undefined : (
596
+ <Button type="button" variant="ghost" size="big" onClick={openSave}>
597
+ {t("emailMePlan")}
598
+ </Button>
599
+ );
456
600
  trailing = undefined;
457
601
  } else {
458
602
  const isCheckout = view === "checkout";
@@ -0,0 +1,269 @@
1
+ // oss: false
2
+ /**
3
+ * Tests for the by-result rows on the results permalink (#10827).
4
+ *
5
+ * `recommend()` returns the first matching row's `routeTo`; the page then
6
+ * sends a checkout row with a shown `_monthly` key straight to the Offerings
7
+ * buy route (a top-level navigation from the browser), and renders an offering row as one card named from the
8
+ * offering — priced, with its `/pricing?plan=` link, only while the offering
9
+ * is shown. A draft (an offering not in the shown set) renders its card with
10
+ * no price and no link, for either kind.
11
+ *
12
+ * Mocks mirror `landing.test.tsx`; the clock moves 16 s between cases so the
13
+ * page's 15 s shown-set read never carries over.
14
+ */
15
+ import { render, screen } from "@testing-library/react";
16
+ import type { FlowConfig, FlowResultRow } from "@working-theory/validation";
17
+ import {
18
+ encodePermalink,
19
+ PERMALINK_VERSION,
20
+ workspaceShownSetWireSchema,
21
+ } from "@working-theory/validation";
22
+ import type { ReactElement } from "react";
23
+
24
+ const mockGet = jest.fn();
25
+ const mockShown = jest.fn();
26
+ jest.mock("@working-theory/api-client", () => ({
27
+ __esModule: true,
28
+ flows: { get: (...args: unknown[]) => mockGet(...args) },
29
+ offerings: { shown: (...args: unknown[]) => mockShown(...args) },
30
+ }));
31
+
32
+ const mockRedirect = jest.fn((url: string) => {
33
+ throw new Error(`NEXT_REDIRECT:${url}`);
34
+ });
35
+ jest.mock("next/navigation", () => ({
36
+ __esModule: true,
37
+ redirect: (url: string) => mockRedirect(url),
38
+ }));
39
+
40
+ jest.mock("next-intl", () => ({
41
+ __esModule: true,
42
+ useTranslations: () => (key: string) => key,
43
+ useMessages: () => ({}),
44
+ }));
45
+
46
+ import Page from "../page";
47
+
48
+ const SLUG = "readiness";
49
+ const en = (s: string) => ({ en: s });
50
+
51
+ /** A scored diagnostic — never 0, monthly 2, weekly 4 points — with rows only. */
52
+ function config(byResult: FlowResultRow[]): FlowConfig {
53
+ return {
54
+ slug: SLUG,
55
+ defaultLocale: "en",
56
+ mode: "recommending",
57
+ steps: [
58
+ {
59
+ key: "q0",
60
+ type: "segmentation",
61
+ copy: { title: en("How often do you ship?") },
62
+ questions: [
63
+ {
64
+ name: "cadence",
65
+ options: [
66
+ { value: "never", label: en("Never") },
67
+ { value: "monthly", label: en("Monthly") },
68
+ { value: "weekly", label: en("Weekly") },
69
+ ],
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ scoring: {
75
+ version: 1,
76
+ points: [
77
+ {
78
+ name: "p_cadence",
79
+ question: "cadence",
80
+ kind: "optionWeights",
81
+ weights: { never: 0, monthly: 2, weekly: 4 },
82
+ },
83
+ ],
84
+ scores: { total: { sum: ["p_cadence"] } },
85
+ tiers: [{ key: "all", when: { score: "total", gte: 0 } }],
86
+ default: "all",
87
+ },
88
+ recommendation: { key: "result", copy: { title: en("Your result") }, byResult },
89
+ } as FlowConfig;
90
+ }
91
+
92
+ const SHOWN_SET = workspaceShownSetWireSchema.parse({
93
+ productIds: ["prod_growth"],
94
+ recommended: { plan: null, addon: null },
95
+ offerings: [
96
+ {
97
+ productId: "prod_growth",
98
+ plan: "growth-ops-growth",
99
+ name: "Growth Ops - Growth",
100
+ description: null,
101
+ marketingFeatures: [],
102
+ unitLabel: null,
103
+ note: null,
104
+ termsNote: null,
105
+ ailkGroup: "plan",
106
+ ailkOrder: null,
107
+ recommended: false,
108
+ prices: [
109
+ {
110
+ priceId: "price_growth_monthly",
111
+ lookupKey: "growth-ops-growth_monthly",
112
+ plan: "growth-ops-growth",
113
+ period: "monthly",
114
+ interval: "month",
115
+ amount: 120000,
116
+ currency: "usd",
117
+ },
118
+ ],
119
+ },
120
+ ],
121
+ state: "current",
122
+ snapshotAt: "2026-11-04T12:00:00.000Z",
123
+ });
124
+
125
+ function page(answers: Record<string, string>): Promise<ReactElement> {
126
+ return Page({
127
+ params: Promise.resolve({
128
+ locale: "en",
129
+ slug: SLUG,
130
+ payload: encodePermalink({ version: PERMALINK_VERSION, flowSlug: SLUG, answers }),
131
+ }),
132
+ });
133
+ }
134
+
135
+ async function redirectOf(answers: Record<string, string>): Promise<string | null> {
136
+ const err = await page(answers).then(
137
+ () => null,
138
+ (e: unknown) => e,
139
+ );
140
+ const message = err instanceof Error ? err.message : "";
141
+ return message.startsWith("NEXT_REDIRECT:") ? message.slice("NEXT_REDIRECT:".length) : null;
142
+ }
143
+
144
+ const checkoutRow = (offeringKey: string): FlowResultRow => ({
145
+ when: { kind: "tier", op: "is", level: 4 },
146
+ routeTo: { kind: "checkout", offeringKey },
147
+ });
148
+ const offeringRow = (offeringKey: string): FlowResultRow => ({
149
+ when: { kind: "answer", op: "atLeast", question: "cadence", value: "monthly" },
150
+ routeTo: { kind: "offering", offeringKey },
151
+ });
152
+
153
+ /** The navigations jsdom was asked for and reports as not implemented. */
154
+ let consoleError: jest.SpyInstance<void, unknown[]>;
155
+ function navigations(): unknown[] {
156
+ return consoleError.mock.calls.filter((args) =>
157
+ args.some((arg: unknown) => String(arg instanceof Error ? arg.message : arg).includes("navigation")),
158
+ );
159
+ }
160
+
161
+ beforeAll(() => {
162
+ jest.useFakeTimers({ now: new Date("2026-11-04T12:00:00.000Z") });
163
+ });
164
+
165
+ afterAll(() => {
166
+ jest.useRealTimers();
167
+ });
168
+
169
+ beforeEach(() => {
170
+ jest.setSystemTime(Date.now() + 16_000);
171
+ mockGet.mockReset();
172
+ mockShown.mockReset();
173
+ mockRedirect.mockClear();
174
+ consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined);
175
+ window.sessionStorage.clear();
176
+ mockShown.mockResolvedValue({ ok: true, status: 200, body: { status: "shown", shownSet: SHOWN_SET } });
177
+ });
178
+
179
+ afterEach(() => {
180
+ consoleError.mockRestore();
181
+ // Every landing is a root-relative path of grammar characters only.
182
+ for (const [url] of mockRedirect.mock.calls) {
183
+ expect(url).toMatch(/^\/[a-z0-9/?=_-]+$/);
184
+ expect(url).not.toContain("//");
185
+ }
186
+ });
187
+
188
+ describe("#10827 — a checkout row sends the respondent to checkout", () => {
189
+ it("a shown _monthly key → a top-level navigation to the Offerings buy route for that key", async () => {
190
+ // The walk arrives by a client-side navigation, which the buy route
191
+ // refuses — so the browser, not a server redirect, goes to checkout.
192
+ mockGet.mockResolvedValue({ ok: true, status: 200, body: config([checkoutRow("growth-ops-growth_monthly")]) });
193
+ // This tab just walked the flow: `FlowStepperClient`'s session entry.
194
+ window.sessionStorage.setItem(`ailk:flow-session:${SLUG}`, "sess_1");
195
+ render(await page({ cadence: "weekly" }));
196
+ expect(mockRedirect).not.toHaveBeenCalled();
197
+ // jsdom performs no navigation; it reports each one it was asked for.
198
+ expect(navigations()).toHaveLength(1);
199
+ expect(screen.getByTestId("recommendation-card-growth-ops-growth_monthly-terminal-getStarted")).toBeInTheDocument();
200
+ // The card stays as the fallback, priced from the offering.
201
+ expect(screen.getByTestId("recommendation-card-growth-ops-growth_monthly-price")).toBeInTheDocument();
202
+ });
203
+
204
+ it.each<[string, Record<string, string> | undefined, boolean]>([
205
+ ["a cold load of a shared permalink (no walk in this tab)", undefined, false],
206
+ ["a ?checkout=cancelled return", { checkout: "cancelled" }, true],
207
+ ])("%s shows the priced card and waits for the click", async (_label, search, walked) => {
208
+ mockGet.mockResolvedValue({ ok: true, status: 200, body: config([checkoutRow("growth-ops-growth_monthly")]) });
209
+ if (walked) window.sessionStorage.setItem(`ailk:flow-session:${SLUG}`, "sess_1");
210
+ render(
211
+ await Page({
212
+ params: Promise.resolve({
213
+ locale: "en",
214
+ slug: SLUG,
215
+ payload: encodePermalink({ version: PERMALINK_VERSION, flowSlug: SLUG, answers: { cadence: "weekly" } }),
216
+ }),
217
+ ...(search !== undefined ? { searchParams: Promise.resolve(search) } : {}),
218
+ }),
219
+ );
220
+ expect(navigations()).toEqual([]);
221
+ expect(screen.getByTestId("recommendation-card-growth-ops-growth_monthly-terminal-getStarted")).toBeInTheDocument();
222
+ });
223
+
224
+ it("a {plan} key → the /pricing?plan= landing", async () => {
225
+ mockGet.mockResolvedValue({ ok: true, status: 200, body: config([checkoutRow("growth-ops-growth")]) });
226
+ await expect(redirectOf({ cadence: "weekly" })).resolves.toBe("/en/pricing?plan=growth-ops-growth");
227
+ });
228
+
229
+ it("a key not shown (a draft) renders its card with no price and no checkout", async () => {
230
+ mockGet.mockResolvedValue({ ok: true, status: 200, body: config([checkoutRow("ai-readiness-workshop_monthly")]) });
231
+ render(await page({ cadence: "weekly" }));
232
+ expect(mockRedirect).not.toHaveBeenCalled();
233
+ const card = screen.getByTestId("recommendation-card-ai-readiness-workshop_monthly");
234
+ expect(card).toHaveTextContent("Ai readiness workshop");
235
+ expect(screen.queryByTestId("recommendation-card-ai-readiness-workshop_monthly-price")).toBeNull();
236
+ expect(screen.queryByTestId("recommendation-card-ai-readiness-workshop_monthly-terminal-getStarted")).toBeNull();
237
+ expect(navigations()).toEqual([]);
238
+ });
239
+ });
240
+
241
+ describe("#10827 — an offering row renders a card named from the offering", () => {
242
+ it("a shown offering: its product name, its price and its /pricing link", async () => {
243
+ mockGet.mockResolvedValue({ ok: true, status: 200, body: config([offeringRow("growth-ops-growth")]) });
244
+ render(await page({ cadence: "monthly" }));
245
+ expect(mockRedirect).not.toHaveBeenCalled();
246
+ expect(screen.getByTestId("recommendation-card-growth-ops-growth")).toHaveTextContent("Growth Ops - Growth");
247
+ expect(screen.getByTestId("recommendation-card-growth-ops-growth-price")).toBeInTheDocument();
248
+ expect(screen.getByTestId("recommendation-card-growth-ops-growth-terminal-getStarted")).toBeInTheDocument();
249
+ expect(navigations()).toEqual([]);
250
+ });
251
+
252
+ it("a draft offering (not shown): the card with no price and no link", async () => {
253
+ mockGet.mockResolvedValue({ ok: true, status: 200, body: config([offeringRow("ai-readiness-workshop")]) });
254
+ render(await page({ cadence: "weekly" }));
255
+ expect(screen.getByTestId("recommendation-card-ai-readiness-workshop")).toHaveTextContent(
256
+ "Ai readiness workshop",
257
+ );
258
+ expect(screen.queryByTestId("recommendation-card-ai-readiness-workshop-price")).toBeNull();
259
+ expect(screen.queryByTestId("recommendation-card-ai-readiness-workshop-terminal-getStarted")).toBeNull();
260
+ });
261
+
262
+ it("no row matches: the results page with no card and no redirect", async () => {
263
+ mockGet.mockResolvedValue({ ok: true, status: 200, body: config([offeringRow("growth-ops-growth")]) });
264
+ render(await page({ cadence: "never" }));
265
+ expect(screen.getByTestId("flow-results")).toBeInTheDocument();
266
+ expect(screen.queryByTestId("recommendation-card-growth-ops-growth")).toBeNull();
267
+ expect(mockRedirect).not.toHaveBeenCalled();
268
+ });
269
+ });