create-ailk 0.27.0 → 0.28.1
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/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -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/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- 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 +224 -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 +237 -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 +279 -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/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the `/[locale]/flows/[slug]/results/[payload]` cold-load route
|
|
4
|
+
* (#4770).
|
|
5
|
+
*
|
|
6
|
+
* AC3 — a payload encoding a known answer set renders exactly the offer
|
|
7
|
+
* names and matched reasons `recommend()` returns for those same
|
|
8
|
+
* answers — asserted against the SAME fixture (`FIXTURE_CONFIG` +
|
|
9
|
+
* `KNOWN_ANSWERS`) `recommend()` itself would score.
|
|
10
|
+
* AC4 — four cases `redirect()` to `/[locale]/flows/[slug]` and render no
|
|
11
|
+
* results markup: invalid payload, stale-version payload, a
|
|
12
|
+
* flowSlug-mismatched payload, and a non-recommending flow.
|
|
13
|
+
*
|
|
14
|
+
* `next/navigation`'s `redirect` is mocked to throw (mirrors the sibling
|
|
15
|
+
* `thanks/[slug]` route's `notFound` test pattern) so a redirect is
|
|
16
|
+
* observable via `.rejects.toThrow` without a real Next.js router. `next-intl`
|
|
17
|
+
* is mocked to an identity translator — `ResultsClient`'s only string comes
|
|
18
|
+
* from the `flow` namespace's `whyThis` key, which this test does not assert
|
|
19
|
+
* on by literal text.
|
|
20
|
+
*/
|
|
21
|
+
import { render, screen } from "@testing-library/react";
|
|
22
|
+
import userEvent from "@testing-library/user-event";
|
|
23
|
+
import type { FlowConfig } from "@working-theory/validation";
|
|
24
|
+
import {
|
|
25
|
+
encodePermalink,
|
|
26
|
+
PERMALINK_VERSION,
|
|
27
|
+
recommend,
|
|
28
|
+
resolveLocalizedString,
|
|
29
|
+
} from "@working-theory/validation";
|
|
30
|
+
|
|
31
|
+
const mockGet = jest.fn();
|
|
32
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
flows: { get: (...args: unknown[]) => mockGet(...args) },
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
const mockRedirect = jest.fn((url: string) => {
|
|
38
|
+
throw new Error(`NEXT_REDIRECT:${url}`);
|
|
39
|
+
});
|
|
40
|
+
jest.mock("next/navigation", () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
redirect: (url: string) => mockRedirect(url),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
jest.mock("next-intl", () => ({
|
|
46
|
+
__esModule: true,
|
|
47
|
+
useTranslations: () => (key: string) => key,
|
|
48
|
+
useMessages: () => ({}),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
import Page, { generateMetadata } from "../page";
|
|
52
|
+
|
|
53
|
+
const SLUG = "ailk-recommender";
|
|
54
|
+
|
|
55
|
+
const KNOWN_ANSWERS = { team_size: "solo", wants_seo: "yes" };
|
|
56
|
+
|
|
57
|
+
function fixtureConfig(overrides: Partial<FlowConfig> = {}): FlowConfig {
|
|
58
|
+
return {
|
|
59
|
+
slug: SLUG,
|
|
60
|
+
defaultLocale: "en",
|
|
61
|
+
mode: "recommending",
|
|
62
|
+
steps: [
|
|
63
|
+
{
|
|
64
|
+
key: "team",
|
|
65
|
+
type: "segmentation",
|
|
66
|
+
copy: { title: { en: "Tell us about your team" } },
|
|
67
|
+
questions: [
|
|
68
|
+
{
|
|
69
|
+
name: "team_size",
|
|
70
|
+
options: [
|
|
71
|
+
{ value: "solo", label: { en: "Solo" } },
|
|
72
|
+
{ value: "agency", label: { en: "Agency" } },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "wants_seo",
|
|
77
|
+
options: [
|
|
78
|
+
{ value: "yes", label: { en: "Yes" } },
|
|
79
|
+
{ value: "no", label: { en: "No" } },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
offers: [
|
|
86
|
+
{
|
|
87
|
+
key: "free_oss",
|
|
88
|
+
copy: {
|
|
89
|
+
name: { en: "Free" },
|
|
90
|
+
description: { en: "Self-serve, open source." },
|
|
91
|
+
},
|
|
92
|
+
price: { kind: "label", label: { en: "Free" } },
|
|
93
|
+
rules: [
|
|
94
|
+
{
|
|
95
|
+
when: { answer: "team_size", eq: "solo" },
|
|
96
|
+
reason: { en: "You're a solo builder." },
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
key: "seo_addon",
|
|
102
|
+
copy: {
|
|
103
|
+
name: { en: "SEO Add-on" },
|
|
104
|
+
description: { en: "AEO + SEO tuning." },
|
|
105
|
+
},
|
|
106
|
+
price: { kind: "label", label: { en: "Add-on" } },
|
|
107
|
+
rules: [
|
|
108
|
+
{
|
|
109
|
+
when: { answer: "wants_seo", eq: "yes" },
|
|
110
|
+
reason: { en: "You want more organic reach." },
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
recommendation: {
|
|
116
|
+
key: "recommendation",
|
|
117
|
+
copy: { title: { en: "Your recommendation" } },
|
|
118
|
+
},
|
|
119
|
+
...overrides,
|
|
120
|
+
} as FlowConfig;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
mockGet.mockReset();
|
|
125
|
+
mockRedirect.mockClear();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
async function expectRedirectTo(
|
|
129
|
+
payload: string,
|
|
130
|
+
expectedUrl: string,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
await expect(
|
|
133
|
+
Page({
|
|
134
|
+
params: Promise.resolve({ locale: "en", slug: SLUG, payload }),
|
|
135
|
+
}),
|
|
136
|
+
).rejects.toThrow(`NEXT_REDIRECT:${expectedUrl}`);
|
|
137
|
+
expect(mockRedirect).toHaveBeenCalledWith(expectedUrl);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe("AC4 — invalid / stale-version / mismatched / non-recommending all redirect to step 1", () => {
|
|
141
|
+
it("redirects on an invalid payload", async () => {
|
|
142
|
+
await expectRedirectTo("not-a-valid-payload!!!", `/en/flows/${SLUG}`);
|
|
143
|
+
// Never fetched the flow config for an undecodable payload.
|
|
144
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("redirects on a stale-version payload", async () => {
|
|
148
|
+
const stalePayload = encodePermalink({
|
|
149
|
+
version: PERMALINK_VERSION - 1,
|
|
150
|
+
flowSlug: SLUG,
|
|
151
|
+
answers: KNOWN_ANSWERS,
|
|
152
|
+
});
|
|
153
|
+
await expectRedirectTo(stalePayload, `/en/flows/${SLUG}`);
|
|
154
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("redirects when the payload's own flowSlug differs from the route's [slug]", async () => {
|
|
158
|
+
const mismatchedPayload = encodePermalink({
|
|
159
|
+
version: PERMALINK_VERSION,
|
|
160
|
+
flowSlug: "a-different-flow",
|
|
161
|
+
answers: KNOWN_ANSWERS,
|
|
162
|
+
});
|
|
163
|
+
await expectRedirectTo(mismatchedPayload, `/en/flows/${SLUG}`);
|
|
164
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("redirects when the served flow is not a recommending flow (no offers)", async () => {
|
|
168
|
+
const payload = encodePermalink({
|
|
169
|
+
version: PERMALINK_VERSION,
|
|
170
|
+
flowSlug: SLUG,
|
|
171
|
+
answers: KNOWN_ANSWERS,
|
|
172
|
+
});
|
|
173
|
+
const {
|
|
174
|
+
offers: _offers,
|
|
175
|
+
mode: _mode,
|
|
176
|
+
...nonRecommending
|
|
177
|
+
} = fixtureConfig();
|
|
178
|
+
mockGet.mockResolvedValueOnce({ ok: true, body: nonRecommending });
|
|
179
|
+
|
|
180
|
+
await expectRedirectTo(payload, `/en/flows/${SLUG}`);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("redirects when the served flow declares mode !== 'recommending'", async () => {
|
|
184
|
+
const payload = encodePermalink({
|
|
185
|
+
version: PERMALINK_VERSION,
|
|
186
|
+
flowSlug: SLUG,
|
|
187
|
+
answers: KNOWN_ANSWERS,
|
|
188
|
+
});
|
|
189
|
+
mockGet.mockResolvedValueOnce({
|
|
190
|
+
ok: true,
|
|
191
|
+
body: fixtureConfig({ mode: "qualifying" }),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await expectRedirectTo(payload, `/en/flows/${SLUG}`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("no results markup renders on any redirect path (renders nothing before throwing)", async () => {
|
|
198
|
+
await expect(
|
|
199
|
+
Page({
|
|
200
|
+
params: Promise.resolve({
|
|
201
|
+
locale: "en",
|
|
202
|
+
slug: SLUG,
|
|
203
|
+
payload: "garbage",
|
|
204
|
+
}),
|
|
205
|
+
}),
|
|
206
|
+
).rejects.toThrow();
|
|
207
|
+
expect(screen.queryByTestId("flow-results")).not.toBeInTheDocument();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("AC3 — a valid payload against a recommending flow renders exactly recommend()'s fits", () => {
|
|
212
|
+
it("renders exactly the offer names and matched reasons recommend() itself returns for the same answers", async () => {
|
|
213
|
+
const user = userEvent.setup();
|
|
214
|
+
const config = fixtureConfig();
|
|
215
|
+
const payload = encodePermalink({
|
|
216
|
+
version: PERMALINK_VERSION,
|
|
217
|
+
flowSlug: SLUG,
|
|
218
|
+
answers: KNOWN_ANSWERS,
|
|
219
|
+
});
|
|
220
|
+
mockGet.mockResolvedValueOnce({ ok: true, body: config });
|
|
221
|
+
|
|
222
|
+
// The SAME fixture (`config` + `KNOWN_ANSWERS`) drives BOTH the mocked
|
|
223
|
+
// server response AND this expectation — `recommend()` is the one
|
|
224
|
+
// source of truth for "which fits, in what order, with which reason,"
|
|
225
|
+
// never a second hand-typed list that could silently drift from it.
|
|
226
|
+
const expected = recommend(
|
|
227
|
+
{
|
|
228
|
+
key: SLUG,
|
|
229
|
+
offers: config.offers,
|
|
230
|
+
pairings: config.pairings,
|
|
231
|
+
rateTable: config.rateTable,
|
|
232
|
+
scoring: config.scoring,
|
|
233
|
+
},
|
|
234
|
+
KNOWN_ANSWERS,
|
|
235
|
+
);
|
|
236
|
+
expect(expected.fits.length).toBeGreaterThan(0);
|
|
237
|
+
|
|
238
|
+
const element = await Page({
|
|
239
|
+
params: Promise.resolve({ locale: "en", slug: SLUG, payload }),
|
|
240
|
+
});
|
|
241
|
+
render(element);
|
|
242
|
+
|
|
243
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
244
|
+
expect(screen.getByTestId("flow-results")).toBeInTheDocument();
|
|
245
|
+
expect(
|
|
246
|
+
screen.getByRole("heading", {
|
|
247
|
+
name: resolveLocalizedString(
|
|
248
|
+
config.recommendation!.copy.title,
|
|
249
|
+
"en",
|
|
250
|
+
config.defaultLocale,
|
|
251
|
+
),
|
|
252
|
+
}),
|
|
253
|
+
).toBeInTheDocument();
|
|
254
|
+
|
|
255
|
+
for (const fit of expected.fits) {
|
|
256
|
+
const offer = config.offers!.find((o) => o.key === fit.offerKey)!;
|
|
257
|
+
const name = resolveLocalizedString(
|
|
258
|
+
offer.copy.name,
|
|
259
|
+
"en",
|
|
260
|
+
config.defaultLocale,
|
|
261
|
+
);
|
|
262
|
+
// #4842 FR1 — the top fit's name is ALSO the safe-default side panel's
|
|
263
|
+
// `offerName`, so the FIRST fit renders exactly twice (panel + card)
|
|
264
|
+
// and every other fit exactly once.
|
|
265
|
+
expect(screen.getAllByText(name)).toHaveLength(
|
|
266
|
+
fit.offerKey === expected.fits[0]!.offerKey ? 2 : 1,
|
|
267
|
+
);
|
|
268
|
+
if (offer.copy.description) {
|
|
269
|
+
expect(
|
|
270
|
+
screen.getByText(
|
|
271
|
+
resolveLocalizedString(
|
|
272
|
+
offer.copy.description,
|
|
273
|
+
"en",
|
|
274
|
+
config.defaultLocale,
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
).toBeInTheDocument();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The matched reason renders inside the "Why this?" Popover
|
|
281
|
+
// disclosure — reveal it (mirrors packages/ui/src/blocks/__tests__/
|
|
282
|
+
// RecommendationCard.test.tsx's own click-to-reveal pattern).
|
|
283
|
+
await user.click(
|
|
284
|
+
screen.getByTestId(`recommendation-card-${fit.offerKey}-why-trigger`),
|
|
285
|
+
);
|
|
286
|
+
expect(
|
|
287
|
+
await screen.findByText(
|
|
288
|
+
resolveLocalizedString(fit.reason, "en", config.defaultLocale),
|
|
289
|
+
),
|
|
290
|
+
).toBeInTheDocument();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("generateMetadata", () => {
|
|
296
|
+
it("resolves the page title from the compiled recommendation marker", async () => {
|
|
297
|
+
const payload = encodePermalink({
|
|
298
|
+
version: PERMALINK_VERSION,
|
|
299
|
+
flowSlug: SLUG,
|
|
300
|
+
answers: KNOWN_ANSWERS,
|
|
301
|
+
});
|
|
302
|
+
mockGet.mockResolvedValueOnce({ ok: true, body: fixtureConfig() });
|
|
303
|
+
|
|
304
|
+
const metadata = await generateMetadata({
|
|
305
|
+
params: Promise.resolve({ locale: "en", slug: SLUG, payload }),
|
|
306
|
+
});
|
|
307
|
+
expect(metadata.title).toBe("Your recommendation");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("returns an empty metadata object for an invalid payload", async () => {
|
|
311
|
+
const metadata = await generateMetadata({
|
|
312
|
+
params: Promise.resolve({
|
|
313
|
+
locale: "en",
|
|
314
|
+
slug: SLUG,
|
|
315
|
+
payload: "garbage",
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
expect(metadata).toEqual({});
|
|
319
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ─── #4842 AC-5 — the `?checkout=` return states (additive; the cases above
|
|
324
|
+
// are the regression floor and stay as they were) ───────────────────────────
|
|
325
|
+
|
|
326
|
+
describe("AC-5 (#4842) — ?checkout= return states render over the same recommendation", () => {
|
|
327
|
+
function validPayload(): string {
|
|
328
|
+
return encodePermalink({
|
|
329
|
+
version: PERMALINK_VERSION,
|
|
330
|
+
flowSlug: SLUG,
|
|
331
|
+
answers: KNOWN_ANSWERS,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function renderWithSearch(
|
|
336
|
+
checkout: string | undefined,
|
|
337
|
+
): Promise<ReturnType<typeof recommend>> {
|
|
338
|
+
const config = fixtureConfig();
|
|
339
|
+
mockGet.mockResolvedValueOnce({ ok: true, body: config });
|
|
340
|
+
const expected = recommend(
|
|
341
|
+
{
|
|
342
|
+
key: SLUG,
|
|
343
|
+
offers: config.offers,
|
|
344
|
+
pairings: config.pairings,
|
|
345
|
+
rateTable: config.rateTable,
|
|
346
|
+
scoring: config.scoring,
|
|
347
|
+
},
|
|
348
|
+
KNOWN_ANSWERS,
|
|
349
|
+
);
|
|
350
|
+
const element = await Page({
|
|
351
|
+
params: Promise.resolve({ locale: "en", slug: SLUG, payload: validPayload() }),
|
|
352
|
+
searchParams: Promise.resolve(checkout === undefined ? {} : { checkout }),
|
|
353
|
+
});
|
|
354
|
+
render(element);
|
|
355
|
+
expect(mockRedirect).not.toHaveBeenCalled();
|
|
356
|
+
return expected;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function expectIdenticalFits(expected: ReturnType<typeof recommend>): void {
|
|
360
|
+
expect(expected.fits.length).toBeGreaterThan(0);
|
|
361
|
+
for (const fit of expected.fits) {
|
|
362
|
+
expect(screen.getByTestId(`recommendation-card-${fit.offerKey}`)).toBeInTheDocument();
|
|
363
|
+
}
|
|
364
|
+
expect(screen.getAllByTestId(/^recommendation-card-[a-z_]+$/)).toHaveLength(
|
|
365
|
+
expected.fits.length,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
it("?checkout=success renders the success status line AND the identical fit cards", async () => {
|
|
370
|
+
const expected = await renderWithSearch("success");
|
|
371
|
+
expect(screen.getByTestId("flow-results-return-state")).toHaveTextContent("checkoutSuccess");
|
|
372
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
373
|
+
expectIdenticalFits(expected);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("?checkout=cancelled renders the cancelled status line AND the identical fit cards", async () => {
|
|
377
|
+
const expected = await renderWithSearch("cancelled");
|
|
378
|
+
expect(screen.getByTestId("flow-results-return-state")).toHaveTextContent("checkoutCancelled");
|
|
379
|
+
expectIdenticalFits(expected);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("an unrecognised value renders no status line; the fits are unchanged", async () => {
|
|
383
|
+
const expected = await renderWithSearch("bogus");
|
|
384
|
+
expect(screen.queryByTestId("flow-results-return-state")).not.toBeInTheDocument();
|
|
385
|
+
expectIdenticalFits(expected);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("a return param never changes which redirect guard fires (invalid payload still redirects to step 1)", async () => {
|
|
389
|
+
await expect(
|
|
390
|
+
Page({
|
|
391
|
+
params: Promise.resolve({ locale: "en", slug: SLUG, payload: "not-a-valid-payload!!!" }),
|
|
392
|
+
searchParams: Promise.resolve({ checkout: "success" }),
|
|
393
|
+
}),
|
|
394
|
+
).rejects.toThrow(`NEXT_REDIRECT:/en/flows/${SLUG}`);
|
|
395
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* checkout-seam.ts — where the results page meets commerce (#6786).
|
|
4
|
+
*
|
|
5
|
+
* The results page is signal-collectors' (it ships with the `flows` surface);
|
|
6
|
+
* checkout is commerce's optional add-on to it. A base file may not import a
|
|
7
|
+
* commerce file — `paidModuleTemplateExclusions()` sweeps every importer of a
|
|
8
|
+
* paid module's `ownedFiles` out of the keyless snapshot, and a scaffold
|
|
9
|
+
* without commerce has neither the file nor `@working-theory/ui-commerce` to
|
|
10
|
+
* resolve. So the dependency points the other way: commerce's
|
|
11
|
+
* `layout.tsx` beside this file provides a `ResultsCheckout` through
|
|
12
|
+
* `ResultsCheckoutContext`, and `ResultsClient` reads it. Next renders a
|
|
13
|
+
* route's `layout.tsx` only when the file exists, so a scaffold without
|
|
14
|
+
* commerce gets no provider, the context stays `undefined`, and the page
|
|
15
|
+
* offers no checkout action — every fit's terminal is Book a Call.
|
|
16
|
+
*
|
|
17
|
+
* Only types and the context live here; nothing in this file names a
|
|
18
|
+
* commerce module, and the types are structural so the commerce half
|
|
19
|
+
* satisfies them without either side importing the other's.
|
|
20
|
+
*/
|
|
21
|
+
import type {
|
|
22
|
+
FlowCheckoutInputRaw,
|
|
23
|
+
RecommendRegister,
|
|
24
|
+
WaitlistBillingPeriod,
|
|
25
|
+
WaitlistCreditStop,
|
|
26
|
+
WaitlistOffer,
|
|
27
|
+
} from "@working-theory/validation";
|
|
28
|
+
import type { ComponentType } from "react";
|
|
29
|
+
import { createContext } from "react";
|
|
30
|
+
|
|
31
|
+
import type { EmailMomentFailure } from "../../email-moment";
|
|
32
|
+
|
|
33
|
+
/** The seats / period / credit stop Pay sends — what the price card shows. */
|
|
34
|
+
export interface ResultsCheckoutConfiguration {
|
|
35
|
+
seats: number;
|
|
36
|
+
period: WaitlistBillingPeriod;
|
|
37
|
+
creditStop: WaitlistCreditStop;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The checkout view's price card, controlled by `ResultsClient`. */
|
|
41
|
+
export interface ResultsPriceCardProps {
|
|
42
|
+
offer: WaitlistOffer;
|
|
43
|
+
currency: string;
|
|
44
|
+
locale: string;
|
|
45
|
+
defaultLocale?: string;
|
|
46
|
+
/** The register `price()` prices against — the same one the server serves. */
|
|
47
|
+
register: RecommendRegister;
|
|
48
|
+
value: ResultsCheckoutConfiguration;
|
|
49
|
+
onChange: (next: ResultsCheckoutConfiguration) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pay's input: the wire body MINUS the two return URLs (the action builds
|
|
54
|
+
* them from the app's own origin, #4875) PLUS the permalink `payload`.
|
|
55
|
+
*/
|
|
56
|
+
export type ResultsCheckoutInput = Omit<
|
|
57
|
+
FlowCheckoutInputRaw,
|
|
58
|
+
"successUrl" | "cancelUrl"
|
|
59
|
+
> & {
|
|
60
|
+
payload: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface ResultsCheckout {
|
|
64
|
+
PriceCard: ComponentType<ResultsPriceCardProps>;
|
|
65
|
+
/** Pay — a returned value is always a failure; success `redirect()`s. */
|
|
66
|
+
start: (
|
|
67
|
+
slug: string,
|
|
68
|
+
input: ResultsCheckoutInput,
|
|
69
|
+
) => Promise<EmailMomentFailure | undefined>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const ResultsCheckoutContext = createContext<
|
|
73
|
+
ResultsCheckout | undefined
|
|
74
|
+
>(undefined);
|