create-ailk 0.27.0 → 0.28.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 +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +38 -4
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The results page without commerce (#6786 AC2) — the Pro default, where the
|
|
3
|
+
* route ships with signal-collectors and commerce's `layout.tsx` is absent.
|
|
4
|
+
*
|
|
5
|
+
* `ResultsClient` is rendered with no `ResultsCheckoutContext` provider, as
|
|
6
|
+
* Next renders it in a scaffold that carries no commerce layout: it shows its
|
|
7
|
+
* non-checkout result — the recommendation, every fit with Book a Call, the
|
|
8
|
+
* pairing, the Book-a-Call rail and "Email me this plan" — and offers no
|
|
9
|
+
* checkout action, even for a fit the register can quote.
|
|
10
|
+
*
|
|
11
|
+
* This suite ships into that scaffold, so it names no commerce file: it mocks
|
|
12
|
+
* only `../../../save.actions` (the boundary is ResultsClient → the save
|
|
13
|
+
* action, covered on its own by `save.actions.test.ts`), plus `next-intl` and
|
|
14
|
+
* `@working-theory/api-client` as `ResultsClient.checkout.test.tsx` does.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { render, screen } from "@testing-library/react";
|
|
20
|
+
import userEvent from "@testing-library/user-event";
|
|
21
|
+
import type { FlowConfig } from "@working-theory/validation";
|
|
22
|
+
|
|
23
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
flows: { get: jest.fn() },
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const mockSave = jest.fn();
|
|
29
|
+
// The boundary under test is ResultsClient's own wiring to its Server Action,
|
|
30
|
+
// not the action itself (save.actions.test.ts) — the same narrow-scope
|
|
31
|
+
// justification ResultsClient.checkout.test.tsx carries.
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- see comment above
|
|
33
|
+
jest.mock("../../../save.actions", () => ({
|
|
34
|
+
saveRecommendationAction: (...args: unknown[]) => mockSave(...args),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.mock("next-intl", () => ({
|
|
38
|
+
__esModule: true,
|
|
39
|
+
useTranslations: () => (key: string) => key,
|
|
40
|
+
useMessages: () => ({}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { ResultsClient } from "../ResultsClient";
|
|
44
|
+
|
|
45
|
+
const SLUG = "ailk-recommender";
|
|
46
|
+
const ANSWERS = { team_size: "agency" };
|
|
47
|
+
|
|
48
|
+
/** A register that CAN quote `pro` — so only commerce's absence withholds Get Started. */
|
|
49
|
+
const CONFIG = {
|
|
50
|
+
slug: SLUG,
|
|
51
|
+
defaultLocale: "en",
|
|
52
|
+
mode: "recommending",
|
|
53
|
+
steps: [
|
|
54
|
+
{
|
|
55
|
+
key: "team",
|
|
56
|
+
type: "segmentation",
|
|
57
|
+
copy: { title: { en: "Team" } },
|
|
58
|
+
questions: [
|
|
59
|
+
{
|
|
60
|
+
name: "team_size",
|
|
61
|
+
options: [
|
|
62
|
+
{ value: "solo", label: { en: "Solo" } },
|
|
63
|
+
{ value: "agency", label: { en: "Agency" } },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
offers: [
|
|
70
|
+
{
|
|
71
|
+
key: "pro",
|
|
72
|
+
copy: { name: { en: "Pro" } },
|
|
73
|
+
price: { kind: "exact", amount: 249, period: "annual" },
|
|
74
|
+
rules: [
|
|
75
|
+
{
|
|
76
|
+
when: { answer: "team_size", eq: "agency" },
|
|
77
|
+
reason: { en: "You run an agency." },
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "consult",
|
|
83
|
+
copy: { name: { en: "Consulting" } },
|
|
84
|
+
price: { kind: "label", label: { en: "Application-based" } },
|
|
85
|
+
rules: [
|
|
86
|
+
{
|
|
87
|
+
when: { answer: "team_size", eq: "agency" },
|
|
88
|
+
reason: { en: "Agencies ask." },
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
rateTable: {
|
|
94
|
+
currency: "usd",
|
|
95
|
+
seatRates: [{ offer: "pro", amount: 249, period: "annual" }],
|
|
96
|
+
creditLadder: {
|
|
97
|
+
denomination: "annual",
|
|
98
|
+
tiers: [{ credits: 10, amount: 99 }],
|
|
99
|
+
custom: { en: "Contact us" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
recommendation: {
|
|
103
|
+
key: "recommendation",
|
|
104
|
+
copy: { title: { en: "Your recommendation" } },
|
|
105
|
+
},
|
|
106
|
+
} as FlowConfig;
|
|
107
|
+
|
|
108
|
+
function renderResults(): void {
|
|
109
|
+
render(
|
|
110
|
+
<ResultsClient
|
|
111
|
+
config={CONFIG}
|
|
112
|
+
locale="en"
|
|
113
|
+
answers={ANSWERS}
|
|
114
|
+
slug={SLUG}
|
|
115
|
+
payload="v1-abc"
|
|
116
|
+
/>,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
mockSave.mockReset();
|
|
122
|
+
mockSave.mockResolvedValue({ ok: true, leadId: "lead_1" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("#6786 AC2 — with commerce absent, the results page renders its non-checkout result", () => {
|
|
126
|
+
it("renders the recommendation and every fit, each with Book a Call — no Get Started", () => {
|
|
127
|
+
renderResults();
|
|
128
|
+
expect(screen.getByTestId("flow-results")).toBeInTheDocument();
|
|
129
|
+
expect(
|
|
130
|
+
screen.getByRole("heading", { name: "Your recommendation" }),
|
|
131
|
+
).toBeInTheDocument();
|
|
132
|
+
expect(
|
|
133
|
+
screen.getByTestId("recommendation-card-pro-terminal-bookCall"),
|
|
134
|
+
).toBeInTheDocument();
|
|
135
|
+
expect(
|
|
136
|
+
screen.getByTestId("recommendation-card-consult-terminal-bookCall"),
|
|
137
|
+
).toBeInTheDocument();
|
|
138
|
+
expect(screen.queryAllByTestId(/-terminal-getStarted$/)).toHaveLength(0);
|
|
139
|
+
expect(
|
|
140
|
+
screen.getByTestId("flow-side-panel-safe-default"),
|
|
141
|
+
).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("offers no checkout action anywhere — no price card, no Pay", () => {
|
|
145
|
+
renderResults();
|
|
146
|
+
expect(screen.queryByTestId("checkout-price-card")).not.toBeInTheDocument();
|
|
147
|
+
expect(screen.queryByTestId("flow-results-pay")).not.toBeInTheDocument();
|
|
148
|
+
expect(
|
|
149
|
+
screen.queryByRole("button", { name: "getStarted" }),
|
|
150
|
+
).not.toBeInTheDocument();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("still saves the plan by email", async () => {
|
|
154
|
+
const user = userEvent.setup();
|
|
155
|
+
renderResults();
|
|
156
|
+
await user.click(screen.getByRole("button", { name: "emailMePlan" }));
|
|
157
|
+
await user.type(
|
|
158
|
+
screen.getByLabelText("emailStepSaveTitle"),
|
|
159
|
+
"jane@example.com",
|
|
160
|
+
);
|
|
161
|
+
await user.click(screen.getByTestId("flow-results-save"));
|
|
162
|
+
expect(mockSave).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(mockSave).toHaveBeenCalledWith(SLUG, {
|
|
164
|
+
answers: ANSWERS,
|
|
165
|
+
email: "jane@example.com",
|
|
166
|
+
locale: "en",
|
|
167
|
+
});
|
|
168
|
+
expect(
|
|
169
|
+
await screen.findByTestId("results-email-step-saved"),
|
|
170
|
+
).toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("#6786 — the page's own files name no commerce module", () => {
|
|
175
|
+
// A base file importing a commerce file is swept out of the keyless
|
|
176
|
+
// snapshot with it (`paidModuleTemplateExclusions`), and a scaffold without
|
|
177
|
+
// commerce has no `@working-theory/ui-commerce` to resolve.
|
|
178
|
+
const RESULTS_DIR = join(__dirname, "..");
|
|
179
|
+
it.each([
|
|
180
|
+
"page.tsx",
|
|
181
|
+
"ResultsClient.tsx",
|
|
182
|
+
"ResultsEmailStep.tsx",
|
|
183
|
+
"checkout-seam.ts",
|
|
184
|
+
"../../save.actions.ts",
|
|
185
|
+
"../../email-moment.ts",
|
|
186
|
+
])(
|
|
187
|
+
"%s imports neither @working-theory/ui-commerce, checkout.actions nor the commerce layout",
|
|
188
|
+
(file) => {
|
|
189
|
+
const source = readFileSync(join(RESULTS_DIR, file), "utf8");
|
|
190
|
+
expect(source).not.toContain('"@working-theory/ui-commerce"');
|
|
191
|
+
expect(source).not.toMatch(/from "[./]*checkout\.actions"/);
|
|
192
|
+
expect(source).not.toMatch(/from "\.\/layout"/);
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
});
|
package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the recommender's landing on the results permalink (#9071 D3,
|
|
4
|
+
* AC-2(a)).
|
|
5
|
+
*
|
|
6
|
+
* A recommending flow with an offer carrying `lookupKey` is an Offerings
|
|
7
|
+
* recommender: its completed walk lands on `/<locale>/pricing?plan=<plan>`
|
|
8
|
+
* while the first fit's Price is in the current shown set, else on
|
|
9
|
+
* `/<locale>/pricing` with no badge. A flow with no such offer, or whose
|
|
10
|
+
* first fit carries none, renders `ResultsClient` exactly as before with no
|
|
11
|
+
* shown-set read (AC-3's `page.test.tsx` holds the legacy render unchanged).
|
|
12
|
+
*
|
|
13
|
+
* `redirect` throws `NEXT_REDIRECT:<url>` as in `page.test.tsx`; the clock is
|
|
14
|
+
* jest's modern fake timers, moved 16 s between cases so the page's 15 s
|
|
15
|
+
* shown-set read never carries over.
|
|
16
|
+
*/
|
|
17
|
+
import { render, screen } from "@testing-library/react";
|
|
18
|
+
import type { FlowConfig, WorkspaceShownSetWire } from "@working-theory/validation";
|
|
19
|
+
import {
|
|
20
|
+
encodePermalink,
|
|
21
|
+
PERMALINK_VERSION,
|
|
22
|
+
workspaceShownSetWireSchema,
|
|
23
|
+
} from "@working-theory/validation";
|
|
24
|
+
import type { ReactElement } from "react";
|
|
25
|
+
|
|
26
|
+
const mockGet = jest.fn();
|
|
27
|
+
const mockShown = jest.fn();
|
|
28
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
flows: { get: (...args: unknown[]) => mockGet(...args) },
|
|
31
|
+
offerings: { shown: (...args: unknown[]) => mockShown(...args) },
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const mockRedirect = jest.fn((url: string) => {
|
|
35
|
+
throw new Error(`NEXT_REDIRECT:${url}`);
|
|
36
|
+
});
|
|
37
|
+
jest.mock("next/navigation", () => ({
|
|
38
|
+
__esModule: true,
|
|
39
|
+
redirect: (url: string) => mockRedirect(url),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
jest.mock("next-intl", () => ({
|
|
43
|
+
__esModule: true,
|
|
44
|
+
useTranslations: () => (key: string) => key,
|
|
45
|
+
useMessages: () => ({}),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import Page from "../page";
|
|
49
|
+
|
|
50
|
+
const SLUG = "recommender";
|
|
51
|
+
const en = (s: string) => ({ en: s });
|
|
52
|
+
|
|
53
|
+
type Offer = NonNullable<FlowConfig["offers"]>[number];
|
|
54
|
+
|
|
55
|
+
function offer(key: string, eq: string, lookupKey?: string): Offer {
|
|
56
|
+
return {
|
|
57
|
+
key,
|
|
58
|
+
copy: { name: en(key) },
|
|
59
|
+
price: { kind: "label", label: en("$29 / month") },
|
|
60
|
+
rules: [{ when: { answer: "team", eq }, reason: en(`Because ${eq}.`) }],
|
|
61
|
+
...(lookupKey !== undefined ? { lookupKey } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function config(offers: unknown = [
|
|
66
|
+
offer("starter", "solo", "starter_monthly"),
|
|
67
|
+
offer("agency", "agency", "agency-pro_yearly"),
|
|
68
|
+
]): FlowConfig {
|
|
69
|
+
return {
|
|
70
|
+
slug: SLUG,
|
|
71
|
+
defaultLocale: "en",
|
|
72
|
+
mode: "recommending",
|
|
73
|
+
steps: [
|
|
74
|
+
{
|
|
75
|
+
key: "q0",
|
|
76
|
+
type: "segmentation",
|
|
77
|
+
copy: { title: en("Who are you?") },
|
|
78
|
+
questions: [
|
|
79
|
+
{
|
|
80
|
+
name: "team",
|
|
81
|
+
options: [
|
|
82
|
+
{ value: "solo", label: en("Solo") },
|
|
83
|
+
{ value: "agency", label: en("Agency") },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
offers,
|
|
90
|
+
} as FlowConfig;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function product(productId: string, plan: string, periods: Array<"monthly" | "yearly">) {
|
|
94
|
+
return {
|
|
95
|
+
productId,
|
|
96
|
+
plan,
|
|
97
|
+
name: plan,
|
|
98
|
+
description: null,
|
|
99
|
+
marketingFeatures: [],
|
|
100
|
+
unitLabel: null,
|
|
101
|
+
note: null,
|
|
102
|
+
termsNote: null,
|
|
103
|
+
ailkGroup: "plan" as const,
|
|
104
|
+
ailkOrder: null,
|
|
105
|
+
recommended: false,
|
|
106
|
+
prices: periods.map((period) => ({
|
|
107
|
+
priceId: `price_${plan}${period}`,
|
|
108
|
+
lookupKey: `${plan}_${period}`,
|
|
109
|
+
plan,
|
|
110
|
+
period,
|
|
111
|
+
interval: period === "monthly" ? ("month" as const) : ("year" as const),
|
|
112
|
+
amount: 2900,
|
|
113
|
+
currency: "usd",
|
|
114
|
+
})),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function shownSet(
|
|
119
|
+
over: Partial<WorkspaceShownSetWire> = {},
|
|
120
|
+
products = [
|
|
121
|
+
product("prod_starter", "starter", ["monthly", "yearly"]),
|
|
122
|
+
product("prod_agency", "agency-pro", ["yearly"]),
|
|
123
|
+
],
|
|
124
|
+
): WorkspaceShownSetWire {
|
|
125
|
+
return workspaceShownSetWireSchema.parse({
|
|
126
|
+
productIds: products.map((p) => p.productId),
|
|
127
|
+
recommended: { plan: null, addon: null },
|
|
128
|
+
offerings: products,
|
|
129
|
+
state: "current",
|
|
130
|
+
snapshotAt: "2026-10-18T12:00:00.000Z",
|
|
131
|
+
...over,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const SHOWN = { ok: true, status: 200, body: { status: "shown", shownSet: shownSet() } };
|
|
136
|
+
|
|
137
|
+
function payloadFor(answers: Record<string, string>): string {
|
|
138
|
+
return encodePermalink({ version: PERMALINK_VERSION, flowSlug: SLUG, answers });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function page(answers: Record<string, string>, locale = "en"): Promise<ReactElement> {
|
|
142
|
+
return Page({
|
|
143
|
+
params: Promise.resolve({ locale, slug: SLUG, payload: payloadFor(answers) }),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function landing(answers: Record<string, string>, locale = "en"): Promise<string> {
|
|
148
|
+
const err = await page(answers, locale).then(
|
|
149
|
+
() => null,
|
|
150
|
+
(e: unknown) => e,
|
|
151
|
+
);
|
|
152
|
+
const message = err instanceof Error ? err.message : "";
|
|
153
|
+
if (!message.startsWith("NEXT_REDIRECT:")) throw new Error(`no redirect: ${String(err)}`);
|
|
154
|
+
return message.slice("NEXT_REDIRECT:".length);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const originalLocales = process.env.NEXT_PUBLIC_ENABLED_LOCALES;
|
|
158
|
+
|
|
159
|
+
beforeAll(() => {
|
|
160
|
+
delete process.env.NEXT_PUBLIC_ENABLED_LOCALES;
|
|
161
|
+
jest.useFakeTimers({ now: new Date("2026-10-18T12:00:00.000Z") });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterAll(() => {
|
|
165
|
+
jest.useRealTimers();
|
|
166
|
+
if (originalLocales !== undefined) process.env.NEXT_PUBLIC_ENABLED_LOCALES = originalLocales;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
// Past the shared read's 15 s window, so no case sees another's read.
|
|
171
|
+
jest.setSystemTime(Date.now() + 16_000);
|
|
172
|
+
mockGet.mockReset();
|
|
173
|
+
mockShown.mockReset();
|
|
174
|
+
mockRedirect.mockClear();
|
|
175
|
+
mockGet.mockResolvedValue({ ok: true, status: 200, body: config() });
|
|
176
|
+
mockShown.mockResolvedValue(SHOWN);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
for (const [url] of mockRedirect.mock.calls) {
|
|
181
|
+
expect(url).toMatch(/^[a-z0-9/?=-]+$/);
|
|
182
|
+
expect(url).not.toContain("_");
|
|
183
|
+
expect(url).not.toContain("://");
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("#9071 AC-2(a) — a shown recommendation lands on /pricing?plan=<plan>", () => {
|
|
188
|
+
it("solo → the starter plan", async () => {
|
|
189
|
+
await expect(landing({ team: "solo" })).resolves.toBe("/en/pricing?plan=starter");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("agency → the agency-pro plan", async () => {
|
|
193
|
+
await expect(landing({ team: "agency" })).resolves.toBe("/en/pricing?plan=agency-pro");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("a locale that is not enabled lands on the default locale", async () => {
|
|
197
|
+
await expect(landing({ team: "solo" }, "fr")).resolves.toBe("/en/pricing?plan=starter");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("#9071 AC-2(a) — a product no longer shown lands on /pricing with no badge", () => {
|
|
202
|
+
it("agency-pro removed from the shown set", async () => {
|
|
203
|
+
mockShown.mockResolvedValue({
|
|
204
|
+
ok: true,
|
|
205
|
+
status: 200,
|
|
206
|
+
body: {
|
|
207
|
+
status: "shown",
|
|
208
|
+
shownSet: shownSet({}, [product("prod_starter", "starter", ["monthly", "yearly"])]),
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
await expect(landing({ team: "agency" })).resolves.toBe("/en/pricing");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it.each<[string, () => unknown]>([
|
|
215
|
+
["unconnected", () => Promise.resolve({ ok: true, status: 200, body: { status: "unconnected" } })],
|
|
216
|
+
["empty", () => Promise.resolve({ ok: true, status: 200, body: { status: "empty" } })],
|
|
217
|
+
[
|
|
218
|
+
"an expired shown set",
|
|
219
|
+
() =>
|
|
220
|
+
Promise.resolve({
|
|
221
|
+
ok: true,
|
|
222
|
+
status: 200,
|
|
223
|
+
body: { status: "shown", shownSet: shownSet({ state: "expired" }) },
|
|
224
|
+
}),
|
|
225
|
+
],
|
|
226
|
+
["a 500", () => Promise.resolve({ ok: false, status: 500, error: { code: "internal_error" } })],
|
|
227
|
+
["a body the schema refuses", () => Promise.resolve({ ok: true, status: 200, body: { status: "shown" } })],
|
|
228
|
+
["a rejecting call", () => Promise.reject(new Error("ECONNREFUSED"))],
|
|
229
|
+
])("%s → /en/pricing", async (_label, answer) => {
|
|
230
|
+
mockShown.mockImplementation(answer);
|
|
231
|
+
await expect(landing({ team: "solo" })).resolves.toBe("/en/pricing");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("a read that never settles is given up after 5 s → /en/pricing", async () => {
|
|
235
|
+
mockShown.mockImplementation(() => new Promise(() => undefined));
|
|
236
|
+
const outcome = landing({ team: "solo" });
|
|
237
|
+
await jest.advanceTimersByTimeAsync(5_000);
|
|
238
|
+
await expect(outcome).resolves.toBe("/en/pricing");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("answers matching no rule → /en/pricing with no shown read", async () => {
|
|
242
|
+
await expect(landing({ team: "other" })).resolves.toBe("/en/pricing");
|
|
243
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("#9071 D3a — one shown-set read per instance per 15 seconds", () => {
|
|
248
|
+
it("two requests at one clock share one read; one 16 s later reads again", async () => {
|
|
249
|
+
await landing({ team: "solo" });
|
|
250
|
+
await landing({ team: "agency" });
|
|
251
|
+
expect(mockShown).toHaveBeenCalledTimes(1);
|
|
252
|
+
jest.setSystemTime(Date.now() + 16_000);
|
|
253
|
+
await landing({ team: "solo" });
|
|
254
|
+
expect(mockShown).toHaveBeenCalledTimes(2);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("#9071 C6 — a flow that is not an Offerings recommender renders as before", () => {
|
|
259
|
+
it("a mixed flow whose first fit has no lookupKey renders ResultsClient, no redirect, no read", async () => {
|
|
260
|
+
mockGet.mockResolvedValue({
|
|
261
|
+
ok: true,
|
|
262
|
+
status: 200,
|
|
263
|
+
body: config([offer("free", "solo"), offer("agency", "agency", "agency-pro_yearly")]),
|
|
264
|
+
});
|
|
265
|
+
render(await page({ team: "solo" }));
|
|
266
|
+
expect(screen.getByTestId("flow-results")).toBeInTheDocument();
|
|
267
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
268
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("an offer with lookupKey: null is no Offerings offer: today's page, no read", async () => {
|
|
272
|
+
mockGet.mockResolvedValue({
|
|
273
|
+
ok: true,
|
|
274
|
+
status: 200,
|
|
275
|
+
body: config([{ ...offer("starter", "solo"), lookupKey: null }]),
|
|
276
|
+
});
|
|
277
|
+
render(await page({ team: "solo" }));
|
|
278
|
+
expect(screen.getByTestId("flow-results")).toBeInTheDocument();
|
|
279
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
280
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("offers that are not an array never reach the landing or the read", async () => {
|
|
284
|
+
mockGet.mockResolvedValue({
|
|
285
|
+
ok: true,
|
|
286
|
+
status: 200,
|
|
287
|
+
body: config({ starter: offer("starter", "solo", "starter_monthly") }),
|
|
288
|
+
});
|
|
289
|
+
await page({ team: "solo" }).catch(() => undefined);
|
|
290
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
291
|
+
expect(mockShown).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
});
|