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
|
@@ -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 {
|
|
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
|
|
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={
|
|
431
|
-
{...(
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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";
|
package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx
ADDED
|
@@ -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
|
+
});
|