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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /pricing/<offering> (#10044 D7, AC-5) — one offering's pricing page.
|
|
3
|
+
*
|
|
4
|
+
* A temporary content root carries `_site.mdx` (the contact email) and
|
|
5
|
+
* `en/Pricing/website-rebuild.mdx`, whose `plans` names `growth` and
|
|
6
|
+
* `seo-audit` and whose sections carry a written plans section and a written
|
|
7
|
+
* add-ons section. The real content adapter, page renderer, mapper and
|
|
8
|
+
* Pricing section run; `global.fetch` answers the shown-offerings route.
|
|
9
|
+
* The page shares one read for 15 seconds, so every case moves `Date.now`.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { act, render, screen, within } from "@testing-library/react";
|
|
16
|
+
import { validateBlock } from "@working-theory/schema-validator";
|
|
17
|
+
import {
|
|
18
|
+
workspaceShownSetSchema,
|
|
19
|
+
type OfferingsPrice,
|
|
20
|
+
type OfferingsProduct,
|
|
21
|
+
} from "@working-theory/validation";
|
|
22
|
+
|
|
23
|
+
const mockNotFound = jest.fn(() => {
|
|
24
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
jest.mock("next/navigation", () => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
notFound: () => mockNotFound(),
|
|
30
|
+
redirect: jest.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import OfferingPricingPage, { dynamic, generateMetadata } from "../page";
|
|
34
|
+
|
|
35
|
+
const SLUG = "website-rebuild";
|
|
36
|
+
const SITE_EMAIL = "hello@example.com";
|
|
37
|
+
|
|
38
|
+
const PAGE = `---
|
|
39
|
+
type: "Pricing"
|
|
40
|
+
slug: "${SLUG}"
|
|
41
|
+
title: "Website Rebuild"
|
|
42
|
+
description: "What a website rebuild costs."
|
|
43
|
+
plans: ["growth", "seo-audit"]
|
|
44
|
+
sections:
|
|
45
|
+
[
|
|
46
|
+
{ "id": "hero", "type": "hero", "data": { "headline": "Rebuild your site", "variant": "default" } },
|
|
47
|
+
{
|
|
48
|
+
"id": "plans",
|
|
49
|
+
"type": "pricing",
|
|
50
|
+
"data": {
|
|
51
|
+
"variant": "three-tiers-with-toggle",
|
|
52
|
+
"billingToggle": false,
|
|
53
|
+
"plans": [
|
|
54
|
+
{ "key": "growth", "name": "Written Growth", "price": { "custom": "$4,900" }, "features": ["Ten pages"], "cta": { "label": "Talk to us", "href": "/contact" } }
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "addons",
|
|
60
|
+
"type": "pricing",
|
|
61
|
+
"data": {
|
|
62
|
+
"variant": "addons",
|
|
63
|
+
"plans": [],
|
|
64
|
+
"addons": [{ "name": "Written extra page", "price": "$150", "features": [] }]
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{ "id": "faq", "type": "faq", "data": { "headline": "Questions", "items": [{ "question": "How long?", "answer": "Six weeks." }] } }
|
|
68
|
+
]
|
|
69
|
+
---
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
const contentRoot = mkdtempSync(join(tmpdir(), "ailk-10044-"));
|
|
73
|
+
const priorContentRoot = process.env.AILK_CONTENT_ROOT;
|
|
74
|
+
|
|
75
|
+
beforeAll(() => {
|
|
76
|
+
Element.prototype.scrollIntoView = jest.fn();
|
|
77
|
+
mkdirSync(join(contentRoot, "en", "Pricing"), { recursive: true });
|
|
78
|
+
writeFileSync(join(contentRoot, "en", "Pricing", `${SLUG}.mdx`), PAGE);
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(contentRoot, "_site.mdx"),
|
|
81
|
+
`---\nsiteName: Example\ncontactEmail: ${SITE_EMAIL}\n---\n`,
|
|
82
|
+
);
|
|
83
|
+
process.env.AILK_CONTENT_ROOT = contentRoot;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
if (priorContentRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
|
|
88
|
+
else process.env.AILK_CONTENT_ROOT = priorContentRoot;
|
|
89
|
+
rmSync(contentRoot, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── The shown set ────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const INTERVAL = { monthly: "month", yearly: "year", once: null } as const;
|
|
95
|
+
|
|
96
|
+
function price(
|
|
97
|
+
plan: string,
|
|
98
|
+
period: OfferingsPrice["period"],
|
|
99
|
+
amount: number,
|
|
100
|
+
): OfferingsPrice {
|
|
101
|
+
return {
|
|
102
|
+
priceId: `price_${plan.replace(/-/g, "")}${period}`,
|
|
103
|
+
lookupKey: `${plan}_${period}`,
|
|
104
|
+
plan,
|
|
105
|
+
period,
|
|
106
|
+
interval: INTERVAL[period],
|
|
107
|
+
amount,
|
|
108
|
+
currency: "usd",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function product(
|
|
113
|
+
plan: string,
|
|
114
|
+
ailkGroup: OfferingsProduct["ailkGroup"],
|
|
115
|
+
name: string,
|
|
116
|
+
prices: OfferingsPrice[],
|
|
117
|
+
): OfferingsProduct {
|
|
118
|
+
return {
|
|
119
|
+
productId: `prod_${plan.replace(/-/g, "")}`,
|
|
120
|
+
plan,
|
|
121
|
+
name,
|
|
122
|
+
description: null,
|
|
123
|
+
marketingFeatures: [],
|
|
124
|
+
unitLabel: null,
|
|
125
|
+
note: null,
|
|
126
|
+
termsNote: null,
|
|
127
|
+
ailkGroup,
|
|
128
|
+
ailkOrder: null,
|
|
129
|
+
prices,
|
|
130
|
+
recommended: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function wireSet(list: OfferingsProduct[]) {
|
|
135
|
+
const {
|
|
136
|
+
workspaceId: _w,
|
|
137
|
+
organizationId: _o,
|
|
138
|
+
...wire
|
|
139
|
+
} = workspaceShownSetSchema.parse({
|
|
140
|
+
workspaceId: "wksp_01890a5d-ac96-774b-bcce-b302099a8057",
|
|
141
|
+
organizationId: "org_01890a5d-ac96-774b-bcce-b302099a8058",
|
|
142
|
+
productIds: list.map((o) => o.productId),
|
|
143
|
+
recommended: { plan: null, addon: null },
|
|
144
|
+
offerings: list,
|
|
145
|
+
state: "current",
|
|
146
|
+
snapshotAt: "2026-10-27T00:00:00.000Z",
|
|
147
|
+
});
|
|
148
|
+
return wire;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const GROWTH = product("growth", "plan", "Growth", [
|
|
152
|
+
price("growth", "monthly", 4900),
|
|
153
|
+
]);
|
|
154
|
+
const SCALE = product("scale", "plan", "Scale", [
|
|
155
|
+
price("scale", "monthly", 9900),
|
|
156
|
+
]);
|
|
157
|
+
const SEO_AUDIT = product("seo-audit", "addon", "SEO audit", [
|
|
158
|
+
price("seo-audit", "monthly", 1500),
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
type Answer = { status: number; body: unknown };
|
|
162
|
+
|
|
163
|
+
function shown(list: OfferingsProduct[]): Answer {
|
|
164
|
+
return {
|
|
165
|
+
status: 200,
|
|
166
|
+
body: { ok: true, data: { status: "shown", shownSet: wireSet(list) } },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const UNCONNECTED: Answer = {
|
|
171
|
+
status: 200,
|
|
172
|
+
body: { ok: true, data: { status: "unconnected" } },
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
let answer: Answer = UNCONNECTED;
|
|
176
|
+
|
|
177
|
+
const fetchMock: jest.Mock = jest.fn(async () => ({
|
|
178
|
+
ok: answer.status >= 200 && answer.status < 300,
|
|
179
|
+
status: answer.status,
|
|
180
|
+
json: async () => answer.body,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
let clock = Date.now();
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
clock += 16_000;
|
|
187
|
+
jest.spyOn(Date, "now").mockImplementation(() => clock);
|
|
188
|
+
global.fetch = fetchMock;
|
|
189
|
+
fetchMock.mockClear();
|
|
190
|
+
mockNotFound.mockClear();
|
|
191
|
+
answer = UNCONNECTED;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
jest.restoreAllMocks();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
async function renderOffering(
|
|
199
|
+
slug: string = SLUG,
|
|
200
|
+
search: Record<string, string> = {},
|
|
201
|
+
): Promise<HTMLElement> {
|
|
202
|
+
const element = await OfferingPricingPage({
|
|
203
|
+
params: Promise.resolve({ locale: "en", slug }),
|
|
204
|
+
searchParams: Promise.resolve(search),
|
|
205
|
+
});
|
|
206
|
+
let container: HTMLElement | undefined;
|
|
207
|
+
await act(async () => {
|
|
208
|
+
({ container } = render(element));
|
|
209
|
+
});
|
|
210
|
+
return container!;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function tierCards(): HTMLElement[] {
|
|
214
|
+
return screen.queryAllByTestId("pricing-tier-card");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function checkoutLinks(): string[] {
|
|
218
|
+
return screen
|
|
219
|
+
.queryAllByRole("link")
|
|
220
|
+
.map((link) => link.getAttribute("href") ?? "")
|
|
221
|
+
.filter((href) => href.includes("/api/offerings/checkout/"));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Every JSON-LD node on the page, `@graph` flattened. */
|
|
225
|
+
function jsonLdNodes(container: HTMLElement): Array<Record<string, unknown>> {
|
|
226
|
+
const script = container.querySelector('script[type="application/ld+json"]');
|
|
227
|
+
if (!script) return [];
|
|
228
|
+
const doc = JSON.parse(script.textContent ?? "") as Record<string, unknown>;
|
|
229
|
+
const graph = doc["@graph"];
|
|
230
|
+
return Array.isArray(graph)
|
|
231
|
+
? (graph as Array<Record<string, unknown>>)
|
|
232
|
+
: [doc];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
describe("/pricing/<offering> (#10044 AC-5)", () => {
|
|
236
|
+
it("renders its written plans and add-ons when Stripe is not connected", async () => {
|
|
237
|
+
await renderOffering();
|
|
238
|
+
|
|
239
|
+
expect(screen.getByText("Written Growth")).not.toBeNull();
|
|
240
|
+
expect(screen.getByText("$4,900")).not.toBeNull();
|
|
241
|
+
expect(screen.getByText("Written extra page")).not.toBeNull();
|
|
242
|
+
expect(screen.getByText("How long?")).not.toBeNull();
|
|
243
|
+
expect(checkoutLinks()).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("renders only its own plans' Stripe cards when connected", async () => {
|
|
247
|
+
answer = shown([GROWTH, SCALE, SEO_AUDIT]);
|
|
248
|
+
await renderOffering();
|
|
249
|
+
|
|
250
|
+
expect(tierCards()).toHaveLength(1);
|
|
251
|
+
expect(within(tierCards()[0]!).getByText("Growth")).not.toBeNull();
|
|
252
|
+
expect(screen.queryByText("Scale")).toBeNull();
|
|
253
|
+
expect(screen.getByText("SEO audit")).not.toBeNull();
|
|
254
|
+
expect(screen.queryByText("Written Growth")).toBeNull();
|
|
255
|
+
expect(screen.queryByText("Written extra page")).toBeNull();
|
|
256
|
+
const links = checkoutLinks();
|
|
257
|
+
expect(links.length).toBeGreaterThanOrEqual(2);
|
|
258
|
+
for (const href of links) {
|
|
259
|
+
expect(href.startsWith("/api/offerings/checkout/")).toBe(true);
|
|
260
|
+
}
|
|
261
|
+
expect(links.some((href) => href.includes("scale"))).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it.each([
|
|
265
|
+
["a shown set carrying none of its plans", shown([SCALE])],
|
|
266
|
+
["a 500", { status: 500, body: { ok: false, error: "internal_error" } }],
|
|
267
|
+
])("renders the contact email for %s", async (_label, failure) => {
|
|
268
|
+
answer = failure;
|
|
269
|
+
await renderOffering();
|
|
270
|
+
|
|
271
|
+
const fallback = screen.getByTestId("contact-fallback");
|
|
272
|
+
expect(fallback.textContent).toContain(SITE_EMAIL);
|
|
273
|
+
expect(tierCards()).toHaveLength(0);
|
|
274
|
+
expect(screen.queryByText("Written Growth")).toBeNull();
|
|
275
|
+
expect(checkoutLinks()).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("marks the Growth card recommended for ?plan=growth", async () => {
|
|
279
|
+
answer = shown([GROWTH, SCALE, SEO_AUDIT]);
|
|
280
|
+
await renderOffering(SLUG, { plan: "growth" });
|
|
281
|
+
|
|
282
|
+
const card = tierCards()[0]!;
|
|
283
|
+
expect(within(card).getByText("Growth")).not.toBeNull();
|
|
284
|
+
expect(within(card).getByText("Recommended for you")).not.toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("emits a WebPage named for the page and a BreadcrumbList ending at /pricing/<slug>", async () => {
|
|
288
|
+
const container = await renderOffering();
|
|
289
|
+
const nodes = jsonLdNodes(container);
|
|
290
|
+
|
|
291
|
+
const webPage = nodes.find((node) => node["@type"] === "WebPage");
|
|
292
|
+
expect(webPage?.["name"]).toBe("Website Rebuild");
|
|
293
|
+
expect(validateBlock(webPage as never, "Pricing")).toEqual(
|
|
294
|
+
expect.objectContaining({ valid: true }),
|
|
295
|
+
);
|
|
296
|
+
const crumbs = nodes.find((node) => node["@type"] === "BreadcrumbList");
|
|
297
|
+
const items = crumbs?.["itemListElement"] as Array<Record<string, unknown>>;
|
|
298
|
+
expect(String(items[items.length - 1]!["item"])).toMatch(
|
|
299
|
+
new RegExp(`/pricing/${SLUG}$`),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("gives a canonical ending /pricing/<slug> and renders per request", async () => {
|
|
304
|
+
const metadata = await generateMetadata({
|
|
305
|
+
params: Promise.resolve({ locale: "en", slug: SLUG }),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(String(metadata.alternates?.canonical)).toMatch(
|
|
309
|
+
new RegExp(`/pricing/${SLUG}$`),
|
|
310
|
+
);
|
|
311
|
+
expect(dynamic).toBe("force-dynamic");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("answers 404 for the hub's own slug and an unknown slug", async () => {
|
|
315
|
+
await expect(renderOffering("pricing")).rejects.toThrow("NEXT_NOT_FOUND");
|
|
316
|
+
await expect(renderOffering("no-such-offering")).rejects.toThrow(
|
|
317
|
+
"NEXT_NOT_FOUND",
|
|
318
|
+
);
|
|
319
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// One offering's pricing page (#10044 D7) — /pricing/<offering>, a `Pricing`
|
|
2
|
+
// content file (`content/<locale>/Pricing/<offering>.mdx`) added with
|
|
3
|
+
// add-page and listed by the /pricing hub. It renders its own sections — its
|
|
4
|
+
// plans, add-ons, FAQ and call to action — in one of three states, the
|
|
5
|
+
// /pricing rules (lib/pricing-page.ts, #8927):
|
|
6
|
+
// - unconnected — its written prices, `?plan=` moving the recommendation;
|
|
7
|
+
// - a current shown set — the Stripe cards of the plans its `plans`
|
|
8
|
+
// frontmatter names in place of its written `pricing` sections;
|
|
9
|
+
// - anything else, or none of its plans shown — the contact email there.
|
|
10
|
+
//
|
|
11
|
+
// The hub's own file (slug `pricing`) is /pricing, never /pricing/pricing.
|
|
12
|
+
// Unknown slugs 404 through renderPage's detail branch (no
|
|
13
|
+
// `translationsNamespace` is passed, #7537).
|
|
14
|
+
//
|
|
15
|
+
// Stripe text reaches markup only as React text and JSON-LD only through
|
|
16
|
+
// renderPage's serializer (T4).
|
|
17
|
+
import type {
|
|
18
|
+
getMdxSource as defaultGetMdxSource,
|
|
19
|
+
getPageSections as defaultGetPageSections,
|
|
20
|
+
} from "@working-theory/page-renderer";
|
|
21
|
+
import {
|
|
22
|
+
getPageSections as loadPageSections,
|
|
23
|
+
renderPage,
|
|
24
|
+
renderPageMetadata,
|
|
25
|
+
} from "@working-theory/page-renderer";
|
|
26
|
+
import type { Metadata } from "next";
|
|
27
|
+
import { notFound } from "next/navigation";
|
|
28
|
+
import type { ReactElement } from "react";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
pagePlans,
|
|
32
|
+
PRICING_HUB_SLUG,
|
|
33
|
+
pricingSections,
|
|
34
|
+
pricingSectionSlots,
|
|
35
|
+
} from "../../../../lib/pricing-page";
|
|
36
|
+
|
|
37
|
+
// Never prerendered; the Offerings read behind it is shared per instance (#8927 D2).
|
|
38
|
+
export const dynamic = "force-dynamic";
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
42
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
43
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
44
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
48
|
+
|
|
49
|
+
export async function generateMetadata({
|
|
50
|
+
params,
|
|
51
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
52
|
+
const { locale, slug } = await params;
|
|
53
|
+
return renderPageMetadata({ type: "Pricing", slug, locale });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default async function Page({
|
|
57
|
+
params,
|
|
58
|
+
searchParams,
|
|
59
|
+
getMdxSource,
|
|
60
|
+
getPageSections,
|
|
61
|
+
}: Props): Promise<ReactElement> {
|
|
62
|
+
const { locale, slug } = await params;
|
|
63
|
+
if (slug === PRICING_HUB_SLUG) {
|
|
64
|
+
notFound();
|
|
65
|
+
}
|
|
66
|
+
const search = (await searchParams) ?? {};
|
|
67
|
+
const plan = Array.isArray(search.plan) ? search.plan[0] : search.plan;
|
|
68
|
+
const loadSections = getPageSections ?? loadPageSections;
|
|
69
|
+
|
|
70
|
+
/** The page's sections in its Offerings state; runs only when it exists. */
|
|
71
|
+
async function withPricing(frontmatter: unknown): Promise<Section[]> {
|
|
72
|
+
return pricingSections(await loadSections(frontmatter), {
|
|
73
|
+
plans: pagePlans(frontmatter),
|
|
74
|
+
plan,
|
|
75
|
+
locale,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return renderPage({
|
|
80
|
+
type: "Pricing",
|
|
81
|
+
slug,
|
|
82
|
+
locale,
|
|
83
|
+
getPageSections: withPricing,
|
|
84
|
+
sectionSlots: await pricingSectionSlots(locale),
|
|
85
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -555,6 +555,9 @@ describe("/pricing shows the contact email when there is nothing to sell (#8927
|
|
|
555
555
|
const OFFERINGS_SOURCES = [
|
|
556
556
|
"apps/web/app/[locale]/pricing/page.tsx",
|
|
557
557
|
"apps/web/lib/offerings-pricing.ts",
|
|
558
|
+
// #10044 — the shared Pricing render and the offering pricing page.
|
|
559
|
+
"apps/web/lib/pricing-page.ts",
|
|
560
|
+
"apps/web/app/[locale]/pricing/[slug]/page.tsx",
|
|
558
561
|
];
|
|
559
562
|
const REPO_ROOT = resolve(__dirname, "../../../../../..");
|
|
560
563
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /pricing lists every offering pricing page (#10044 D6, AC-4).
|
|
3
|
+
*
|
|
4
|
+
* The hub's source and sections are DI-injected and `listPages` answers the
|
|
5
|
+
* offering pages, so the real renderPage, SectionRenderer and Items section
|
|
6
|
+
* run and the assertions read the served DOM. A hub with nothing to list is
|
|
7
|
+
* compared with `renderPage` called directly, the `unconnected.test.tsx`
|
|
8
|
+
* equality-oracle shape.
|
|
9
|
+
*
|
|
10
|
+
* `global.fetch` answers the shown-offerings route; each case moves
|
|
11
|
+
* `Date.now` past the page's 15-second shared read.
|
|
12
|
+
*/
|
|
13
|
+
import { act, render, screen, within } from "@testing-library/react";
|
|
14
|
+
import { renderPage } from "@working-theory/page-renderer";
|
|
15
|
+
import type { ReactElement } from "react";
|
|
16
|
+
|
|
17
|
+
import type { DetailPage } from "../../../../lib/route-family";
|
|
18
|
+
import PricingPage from "../page";
|
|
19
|
+
|
|
20
|
+
const HUB_FRONTMATTER = {
|
|
21
|
+
type: "Pricing",
|
|
22
|
+
slug: "pricing",
|
|
23
|
+
title: "Pricing",
|
|
24
|
+
description: "Plans for every stage.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SECTIONS = [
|
|
28
|
+
{
|
|
29
|
+
id: "hero",
|
|
30
|
+
type: "hero",
|
|
31
|
+
data: { headline: "Find your plan", variant: "default" },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "plans",
|
|
35
|
+
type: "pricing",
|
|
36
|
+
data: {
|
|
37
|
+
variant: "three-tiers-with-toggle",
|
|
38
|
+
billingToggle: false,
|
|
39
|
+
plans: [
|
|
40
|
+
{
|
|
41
|
+
key: "basic",
|
|
42
|
+
name: "Written Basic",
|
|
43
|
+
price: { monthly: "$29" },
|
|
44
|
+
features: ["One site"],
|
|
45
|
+
cta: { label: "Choose Basic", href: "/checkout/basic" },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "closing",
|
|
52
|
+
type: "cta",
|
|
53
|
+
data: {
|
|
54
|
+
headline: "Still deciding?",
|
|
55
|
+
primaryCta: { label: "Talk to us", href: "/contact" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function source(frontmatter: Record<string, unknown>) {
|
|
61
|
+
return jest.fn().mockResolvedValue({ frontmatter, body: "" });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const getPageSections = jest.fn().mockResolvedValue(SECTIONS);
|
|
65
|
+
|
|
66
|
+
function pages(...list: DetailPage[]): jest.Mock {
|
|
67
|
+
return jest.fn().mockResolvedValue(list);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const A: DetailPage = {
|
|
71
|
+
slug: "a",
|
|
72
|
+
frontmatter: {
|
|
73
|
+
type: "Pricing",
|
|
74
|
+
slug: "a",
|
|
75
|
+
title: "Offering A",
|
|
76
|
+
description: "Description A.",
|
|
77
|
+
summary: "Summary A.",
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const B: DetailPage = {
|
|
81
|
+
slug: "b",
|
|
82
|
+
frontmatter: {
|
|
83
|
+
type: "Pricing",
|
|
84
|
+
slug: "b",
|
|
85
|
+
title: "Offering B",
|
|
86
|
+
description: "Description B.",
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const HUB: DetailPage = { slug: "pricing", frontmatter: HUB_FRONTMATTER };
|
|
90
|
+
|
|
91
|
+
let shownAnswer: unknown = { status: "unconnected" };
|
|
92
|
+
|
|
93
|
+
const fetchMock: jest.Mock = jest.fn(async () => ({
|
|
94
|
+
ok: true,
|
|
95
|
+
status: 200,
|
|
96
|
+
json: async () => ({ ok: true, data: shownAnswer }),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
let clock = Date.now();
|
|
100
|
+
|
|
101
|
+
beforeAll(() => {
|
|
102
|
+
Element.prototype.scrollIntoView = jest.fn();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
clock += 16_000;
|
|
107
|
+
jest.spyOn(Date, "now").mockImplementation(() => clock);
|
|
108
|
+
global.fetch = fetchMock;
|
|
109
|
+
fetchMock.mockClear();
|
|
110
|
+
shownAnswer = { status: "unconnected" };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
jest.restoreAllMocks();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** `useId` values renumbered by first appearance, as `unconnected.test.tsx` does. */
|
|
118
|
+
function stableIds(markup: string): string {
|
|
119
|
+
const seen = new Map<string, string>();
|
|
120
|
+
return markup.replace(/_r_[0-9a-z]+_/g, (id) => {
|
|
121
|
+
if (!seen.has(id)) seen.set(id, `_r_id${seen.size}_`);
|
|
122
|
+
return seen.get(id)!;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function html(element: ReactElement): Promise<string> {
|
|
127
|
+
let container: HTMLElement | undefined;
|
|
128
|
+
await act(async () => {
|
|
129
|
+
({ container } = render(element));
|
|
130
|
+
});
|
|
131
|
+
return stableIds(container!.innerHTML);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function hub(
|
|
135
|
+
listPages: jest.Mock,
|
|
136
|
+
frontmatter: Record<string, unknown> = HUB_FRONTMATTER,
|
|
137
|
+
): Promise<ReactElement> {
|
|
138
|
+
return PricingPage({
|
|
139
|
+
params: Promise.resolve({ locale: "en" }),
|
|
140
|
+
searchParams: Promise.resolve({}),
|
|
141
|
+
getMdxSource: source(frontmatter),
|
|
142
|
+
getPageSections,
|
|
143
|
+
listPages,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True when `a` comes before `b` in document order. */
|
|
148
|
+
function before(a: Element, b: Element): boolean {
|
|
149
|
+
return (
|
|
150
|
+
(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe("/pricing lists the offering pricing pages (#10044 AC-4)", () => {
|
|
155
|
+
it("renders one card per offering page in slug order, before the closing CTA", async () => {
|
|
156
|
+
await html(await hub(pages(A, B, HUB)));
|
|
157
|
+
|
|
158
|
+
const heading = screen.getByText("Pricing by offering");
|
|
159
|
+
const list = heading.closest("section") ?? document.body;
|
|
160
|
+
const links = within(list)
|
|
161
|
+
.getAllByRole("link")
|
|
162
|
+
.map((link) => link.getAttribute("href"));
|
|
163
|
+
expect(links.filter((href) => href?.includes("/pricing/"))).toEqual([
|
|
164
|
+
expect.stringMatching(/\/pricing\/a$/),
|
|
165
|
+
expect.stringMatching(/\/pricing\/b$/),
|
|
166
|
+
]);
|
|
167
|
+
expect(within(list).getByText("Offering A")).not.toBeNull();
|
|
168
|
+
expect(within(list).getByText("Summary A.")).not.toBeNull();
|
|
169
|
+
expect(within(list).queryByText("Description A.")).toBeNull();
|
|
170
|
+
expect(within(list).getByText("Description B.")).not.toBeNull();
|
|
171
|
+
expect(within(list).queryByText("Pricing", { exact: true })).toBeNull();
|
|
172
|
+
expect(before(heading, screen.getByText("Still deciding?"))).toBe(true);
|
|
173
|
+
expect(before(screen.getByText("Written Basic"), heading)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it.each([
|
|
177
|
+
["only the hub's own page", [HUB]],
|
|
178
|
+
[
|
|
179
|
+
"the hub plus a file whose frontmatter slug is `pricing`",
|
|
180
|
+
[HUB, { slug: "x", frontmatter: { ...HUB_FRONTMATTER } }],
|
|
181
|
+
],
|
|
182
|
+
])("renders today's page with %s", async (_label, list) => {
|
|
183
|
+
const served = await html(await hub(pages(...list)));
|
|
184
|
+
const oracle = await html(
|
|
185
|
+
await renderPage({
|
|
186
|
+
type: "Pricing",
|
|
187
|
+
slug: "pricing",
|
|
188
|
+
locale: "en",
|
|
189
|
+
translationsNamespace: "pricing",
|
|
190
|
+
getMdxSource: source(HUB_FRONTMATTER),
|
|
191
|
+
getPageSections,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(served).toBe(oracle);
|
|
196
|
+
expect(served).not.toContain("Pricing by offering");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("with the hub's `plans: []` and a shown set, renders its written sections and no Stripe card or contact section", async () => {
|
|
200
|
+
shownAnswer = { status: "shown", shownSet: { state: "current" } };
|
|
201
|
+
await html(await hub(pages(A), { ...HUB_FRONTMATTER, plans: [] }));
|
|
202
|
+
|
|
203
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
204
|
+
expect(screen.getByText("Written Basic")).not.toBeNull();
|
|
205
|
+
expect(screen.queryByTestId("contact-fallback")).toBeNull();
|
|
206
|
+
expect(
|
|
207
|
+
screen
|
|
208
|
+
.queryAllByRole("link")
|
|
209
|
+
.some((link) =>
|
|
210
|
+
link.getAttribute("href")?.includes("/api/offerings/checkout/"),
|
|
211
|
+
),
|
|
212
|
+
).toBe(false);
|
|
213
|
+
expect(screen.getByText("Pricing by offering")).not.toBeNull();
|
|
214
|
+
});
|
|
215
|
+
});
|