create-ailk 0.22.0 → 0.23.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/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +9 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +98 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +184 -11
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +77 -16
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +10 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +315 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +31 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +31 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace shown set → the Pricing section's data (#8925, spec
|
|
3
|
+
* docs/specs/8925-map-the-workspace-shown-set-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* One pure function, `offeringsPricing`, turns the Offerings shown set (spec
|
|
6
|
+
* 8876 D4, served by #8924) into two Pricing section fragments: one plan card
|
|
7
|
+
* per `ailkGroup: "plan"` Product and one add-on tile per `"addon"` Product,
|
|
8
|
+
* in the snapshot's order, each keyed by the Product's `{plan}` so `?plan=`
|
|
9
|
+
* (#8842) can match it. #8927 places the fragments into `/pricing`.
|
|
10
|
+
*
|
|
11
|
+
* This is the one place Stripe text crosses into page markup, so contract
|
|
12
|
+
* docs/specs/8879-the-offerings-security-contract-key-spec.md binds it:
|
|
13
|
+
* - T2 — every Stripe string is emitted as a plain text field, with format
|
|
14
|
+
* characters removed; nothing is parsed as HTML or Markdown (D8).
|
|
15
|
+
* - T3 — every link is `OFFERINGS_CHECKOUT_PATH` plus a lookup key that
|
|
16
|
+
* `parseLookupKey` accepts; no href is built from any other field (D5).
|
|
17
|
+
* - §3.7 — `yearly` reads as annual and `once` as one-time at render, never
|
|
18
|
+
* in the key (D2).
|
|
19
|
+
*
|
|
20
|
+
* The module holds no user-visible copy: the CTA label, the recommended label
|
|
21
|
+
* and the period phrasings arrive in `labels` (D9, customer-facing-i18n).
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
parseLookupKey,
|
|
25
|
+
lookupKeyPeriodSchema,
|
|
26
|
+
workspaceShownSetSchema,
|
|
27
|
+
type LookupKeyPeriod,
|
|
28
|
+
type OfferingsPrice,
|
|
29
|
+
type OfferingsProduct,
|
|
30
|
+
type PageSection,
|
|
31
|
+
} from "@working-theory/validation";
|
|
32
|
+
|
|
33
|
+
/** The period words, read by name from their one home (#8876 AC-9). */
|
|
34
|
+
const PERIOD = lookupKeyPeriodSchema.enum;
|
|
35
|
+
|
|
36
|
+
type PricingSectionData = Extract<PageSection, { type: "pricing" }>["data"];
|
|
37
|
+
type PricingPlanCard = PricingSectionData["plans"][number];
|
|
38
|
+
/** `AddonCard` ignores `key`; it is carried so a tile names its `{plan}` too. */
|
|
39
|
+
type PricingAddonTile = NonNullable<PricingSectionData["addons"]>[number] & {
|
|
40
|
+
key: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface OfferingsPricingLabels {
|
|
44
|
+
/** The CTA label on every card and tile. */
|
|
45
|
+
cta: string;
|
|
46
|
+
/** The recommended plan's pill and the recommended add-on's eyebrow. */
|
|
47
|
+
recommended: string;
|
|
48
|
+
/** A formatted amount → its monthly, annual and one-time phrasings. */
|
|
49
|
+
monthly(amount: string): string;
|
|
50
|
+
annual(amount: string): string;
|
|
51
|
+
oneTime(amount: string): string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OfferingsPricing {
|
|
55
|
+
plans: PricingSectionData | null;
|
|
56
|
+
addons: PricingSectionData | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The route every Offerings CTA links to, followed by a grammar key. */
|
|
60
|
+
export const OFFERINGS_CHECKOUT_PATH = "/api/offerings/checkout";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The CTA target for `lookupKey`, or `null` when the key is outside the
|
|
64
|
+
* grammar. A grammar key is `[a-z0-9_-]` only, so it needs no encoding.
|
|
65
|
+
*/
|
|
66
|
+
export function offeringsCheckoutHref(lookupKey: string): string | null {
|
|
67
|
+
return parseLookupKey(lookupKey)
|
|
68
|
+
? `${OFFERINGS_CHECKOUT_PATH}/${lookupKey}`
|
|
69
|
+
: null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `lookupKey` is a grammar key of a Price in a current, non-empty
|
|
74
|
+
* shown set — the keys `/pricing`'s cards are built from (#9051 D4).
|
|
75
|
+
*/
|
|
76
|
+
export function isShownLookupKey(
|
|
77
|
+
shownSet: unknown,
|
|
78
|
+
lookupKey: string,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (!parseLookupKey(lookupKey)) return false;
|
|
81
|
+
const parsed = shownSetSchema.safeParse(shownSet);
|
|
82
|
+
if (!parsed.success) return false;
|
|
83
|
+
const { state, offerings } = parsed.data;
|
|
84
|
+
if (state !== "current" || offerings.length === 0) return false;
|
|
85
|
+
return offerings.some((o) => o.prices.some((p) => p.lookupKey === lookupKey));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Stripe's zero- and three-decimal currencies. The spoke's legacy buy route
|
|
89
|
+
// held the only other copy across apps/web and @working-theory/spoke-checkout,
|
|
90
|
+
// and is retired (#9142). The table stays here, not in that package: a hub and
|
|
91
|
+
// a spoke without commerce carry /pricing but not the package.
|
|
92
|
+
const ZERO_DECIMAL_CURRENCIES = new Set([
|
|
93
|
+
"bif",
|
|
94
|
+
"clp",
|
|
95
|
+
"djf",
|
|
96
|
+
"gnf",
|
|
97
|
+
"jpy",
|
|
98
|
+
"kmf",
|
|
99
|
+
"krw",
|
|
100
|
+
"mga",
|
|
101
|
+
"pyg",
|
|
102
|
+
"rwf",
|
|
103
|
+
"ugx",
|
|
104
|
+
"vnd",
|
|
105
|
+
"vuv",
|
|
106
|
+
"xaf",
|
|
107
|
+
"xof",
|
|
108
|
+
"xpf",
|
|
109
|
+
]);
|
|
110
|
+
const THREE_DECIMAL_CURRENCIES = new Set(["bhd", "jod", "kwd", "omr", "tnd"]);
|
|
111
|
+
|
|
112
|
+
function currencyExponent(currency: string): number {
|
|
113
|
+
if (ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
|
|
114
|
+
if (THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* An amount in Stripe's minor units, formatted for `locale` (D7). A whole
|
|
120
|
+
* amount drops its fraction digits; a locale `Intl` refuses falls back to `en`.
|
|
121
|
+
*/
|
|
122
|
+
export function formatMinorUnits(
|
|
123
|
+
amount: number,
|
|
124
|
+
currency: string,
|
|
125
|
+
locale: string,
|
|
126
|
+
): string {
|
|
127
|
+
const code = currency.toLowerCase();
|
|
128
|
+
const exponent = currencyExponent(code);
|
|
129
|
+
const value = amount / 10 ** exponent;
|
|
130
|
+
const options: Intl.NumberFormatOptions = {
|
|
131
|
+
style: "currency",
|
|
132
|
+
currency: code.toUpperCase(),
|
|
133
|
+
maximumFractionDigits: exponent,
|
|
134
|
+
minimumFractionDigits: Number.isInteger(value) ? 0 : exponent,
|
|
135
|
+
};
|
|
136
|
+
let format: Intl.NumberFormat;
|
|
137
|
+
try {
|
|
138
|
+
format = new Intl.NumberFormat(locale, options);
|
|
139
|
+
} catch {
|
|
140
|
+
format = new Intl.NumberFormat("en", options);
|
|
141
|
+
}
|
|
142
|
+
return format.format(value);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Cc, Cf, Zl, Zp and Cs — the T1 classes. The input was normalized once by
|
|
146
|
+
// #8876, so this changes nothing there; it is the last guard before markup.
|
|
147
|
+
const FORMAT_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/gu;
|
|
148
|
+
|
|
149
|
+
function stripFormatCharacters(text: string, keepNewlines = false): string {
|
|
150
|
+
return text.replace(FORMAT_CHARACTERS, (ch) =>
|
|
151
|
+
keepNewlines && ch === "\n" ? ch : "",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function features(product: OfferingsProduct): string[] {
|
|
156
|
+
return product.marketingFeatures
|
|
157
|
+
.map((feature) => stripFormatCharacters(feature))
|
|
158
|
+
.filter((feature) => feature.length > 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function priceFor(
|
|
162
|
+
product: OfferingsProduct,
|
|
163
|
+
period: LookupKeyPeriod,
|
|
164
|
+
): OfferingsPrice | undefined {
|
|
165
|
+
return product.prices.find((p) => p.period === period);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The Price a card shows in monthly mode: `monthly`, else `yearly`, else `once`. */
|
|
169
|
+
function shownPrice(product: OfferingsProduct): OfferingsPrice | undefined {
|
|
170
|
+
return (
|
|
171
|
+
priceFor(product, PERIOD.monthly) ??
|
|
172
|
+
priceFor(product, PERIOD.yearly) ??
|
|
173
|
+
priceFor(product, PERIOD.once)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function phrase(
|
|
178
|
+
price: OfferingsPrice,
|
|
179
|
+
locale: string,
|
|
180
|
+
labels: OfferingsPricingLabels,
|
|
181
|
+
): string {
|
|
182
|
+
const amount = formatMinorUnits(price.amount, price.currency, locale);
|
|
183
|
+
if (price.period === PERIOD.monthly) return labels.monthly(amount);
|
|
184
|
+
if (price.period === PERIOD.yearly) return labels.annual(amount);
|
|
185
|
+
return labels.oneTime(amount);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Only the first `recommended` Product of a group is highlighted (D4). Each
|
|
190
|
+
* offering's own flag is read; the shown set's top-level `recommended` ids
|
|
191
|
+
* carry the same fact and are deliberately not consulted a second time.
|
|
192
|
+
*/
|
|
193
|
+
function firstRecommended(products: OfferingsProduct[]): string | undefined {
|
|
194
|
+
return products.find((p) => p.recommended)?.plan;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function planCard(
|
|
198
|
+
product: OfferingsProduct,
|
|
199
|
+
highlighted: boolean,
|
|
200
|
+
locale: string,
|
|
201
|
+
labels: OfferingsPricingLabels,
|
|
202
|
+
): PricingPlanCard | null {
|
|
203
|
+
const shown = shownPrice(product);
|
|
204
|
+
const href = shown ? offeringsCheckoutHref(shown.lookupKey) : null;
|
|
205
|
+
if (!href) return null;
|
|
206
|
+
|
|
207
|
+
const monthly = priceFor(product, PERIOD.monthly);
|
|
208
|
+
const yearly = priceFor(product, PERIOD.yearly);
|
|
209
|
+
const once = priceFor(product, PERIOD.once);
|
|
210
|
+
const price: PricingPlanCard["price"] = {};
|
|
211
|
+
if (monthly) price.monthly = phrase(monthly, locale, labels);
|
|
212
|
+
if (yearly) price.annual = phrase(yearly, locale, labels);
|
|
213
|
+
if (once && !monthly && !yearly) price.custom = phrase(once, locale, labels);
|
|
214
|
+
|
|
215
|
+
const card: PricingPlanCard = {
|
|
216
|
+
key: product.plan,
|
|
217
|
+
name: stripFormatCharacters(product.name),
|
|
218
|
+
price,
|
|
219
|
+
features: features(product),
|
|
220
|
+
cta: { label: labels.cta, href },
|
|
221
|
+
};
|
|
222
|
+
const description =
|
|
223
|
+
product.description && stripFormatCharacters(product.description, true);
|
|
224
|
+
if (description) card.description = description;
|
|
225
|
+
if (product.unitLabel)
|
|
226
|
+
card.priceUnit = stripFormatCharacters(product.unitLabel);
|
|
227
|
+
if (product.note) card.priceSubhead = stripFormatCharacters(product.note);
|
|
228
|
+
if (product.termsNote)
|
|
229
|
+
card.priceFootnote = stripFormatCharacters(product.termsNote);
|
|
230
|
+
// Annual mode shows the yearly Price only when a monthly one is beside it,
|
|
231
|
+
// so only then does the card need a second link (D5, D6).
|
|
232
|
+
const annualHref =
|
|
233
|
+
monthly && yearly && offeringsCheckoutHref(yearly.lookupKey);
|
|
234
|
+
if (annualHref) card.annualCtaHref = annualHref;
|
|
235
|
+
if (highlighted) {
|
|
236
|
+
card.highlighted = true;
|
|
237
|
+
card.highlightStyle = "pill";
|
|
238
|
+
card.highlightLabel = labels.recommended;
|
|
239
|
+
}
|
|
240
|
+
return card;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function addonTile(
|
|
244
|
+
product: OfferingsProduct,
|
|
245
|
+
highlighted: boolean,
|
|
246
|
+
locale: string,
|
|
247
|
+
labels: OfferingsPricingLabels,
|
|
248
|
+
): PricingAddonTile | null {
|
|
249
|
+
const shown = shownPrice(product);
|
|
250
|
+
const href = shown && offeringsCheckoutHref(shown.lookupKey);
|
|
251
|
+
if (!shown || !href) return null;
|
|
252
|
+
|
|
253
|
+
const tile: PricingAddonTile = {
|
|
254
|
+
key: product.plan,
|
|
255
|
+
name: stripFormatCharacters(product.name),
|
|
256
|
+
price: phrase(shown, locale, labels),
|
|
257
|
+
features: features(product),
|
|
258
|
+
cta: { label: labels.cta, href },
|
|
259
|
+
};
|
|
260
|
+
if (product.unitLabel)
|
|
261
|
+
tile.qualifier = stripFormatCharacters(product.unitLabel);
|
|
262
|
+
if (highlighted) {
|
|
263
|
+
tile.eyebrow = labels.recommended;
|
|
264
|
+
tile.checkTone = "primary";
|
|
265
|
+
}
|
|
266
|
+
return tile;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// #8927 D3 — `GET /v1/content/shown-offerings` sends the shown set without
|
|
270
|
+
// `workspaceId` and `organizationId` (spec 8924 D3), which the strict schema
|
|
271
|
+
// requires. Both ids become optional; an id that is present is still
|
|
272
|
+
// validated, every other field is still required and an unknown key is still
|
|
273
|
+
// refused.
|
|
274
|
+
const shownSetSchema = workspaceShownSetSchema.partial({
|
|
275
|
+
workspaceId: true,
|
|
276
|
+
organizationId: true,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
function isPresent<T>(value: T | null): value is T {
|
|
280
|
+
return value !== null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The shown set as Pricing section data, or `null` when there is nothing to
|
|
285
|
+
* show: a value the shown-set schema refuses, a snapshot that is not
|
|
286
|
+
* `current`, or no offerings. Each fragment is `null` when its group is empty.
|
|
287
|
+
*/
|
|
288
|
+
export function offeringsPricing(
|
|
289
|
+
shownSet: unknown,
|
|
290
|
+
options: { locale: string; labels: OfferingsPricingLabels },
|
|
291
|
+
): OfferingsPricing | null {
|
|
292
|
+
const parsed = shownSetSchema.safeParse(shownSet);
|
|
293
|
+
if (!parsed.success) return null;
|
|
294
|
+
const { state, offerings } = parsed.data;
|
|
295
|
+
if (state !== "current" || offerings.length === 0) return null;
|
|
296
|
+
const { locale, labels } = options;
|
|
297
|
+
|
|
298
|
+
const planProducts = offerings.filter((o) => o.ailkGroup === "plan");
|
|
299
|
+
const addonProducts = offerings.filter((o) => o.ailkGroup === "addon");
|
|
300
|
+
const recommendedPlan = firstRecommended(planProducts);
|
|
301
|
+
const recommendedAddon = firstRecommended(addonProducts);
|
|
302
|
+
|
|
303
|
+
const plans = planProducts
|
|
304
|
+
.map((p) => planCard(p, p.plan === recommendedPlan, locale, labels))
|
|
305
|
+
.filter(isPresent);
|
|
306
|
+
const addons = addonProducts
|
|
307
|
+
.map((p) => addonTile(p, p.plan === recommendedAddon, locale, labels))
|
|
308
|
+
.filter(isPresent);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
plans:
|
|
312
|
+
plans.length > 0
|
|
313
|
+
? {
|
|
314
|
+
variant: "three-tiers-with-toggle",
|
|
315
|
+
cardLayout: "centered",
|
|
316
|
+
billingToggle: plans.some(
|
|
317
|
+
(card) =>
|
|
318
|
+
card.price.monthly !== undefined &&
|
|
319
|
+
card.price.annual !== undefined,
|
|
320
|
+
),
|
|
321
|
+
plans,
|
|
322
|
+
}
|
|
323
|
+
: null,
|
|
324
|
+
addons: addons.length > 0 ? { variant: "addons", plans: [], addons } : null,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The posting a `/careers/<slug>/apply` page applies to, read by its title
|
|
3
|
+
* (#8840 D11). The read sits here, not in a page, so the structured-data walk
|
|
4
|
+
* does not take an apply page for a JobPosting page: the posting's own JSON-LD
|
|
5
|
+
* is `/careers/<slug>`'s. In `lib/` (#8849 D9), not a route directory, so
|
|
6
|
+
* every scaffold carries it: the `signal-collectors` walk and the Free apply
|
|
7
|
+
* page (`careers/[slug]/[action]`) both read it.
|
|
8
|
+
*/
|
|
9
|
+
import { getMdxSource } from "@working-theory/page-renderer";
|
|
10
|
+
|
|
11
|
+
/** The posting's title in `locale`, or `null` when no posting has this slug. */
|
|
12
|
+
export async function postingTitle(slug: string, locale: string): Promise<string | null> {
|
|
13
|
+
const source = await getMdxSource("JobPosting", slug, locale);
|
|
14
|
+
if (source === null) return null;
|
|
15
|
+
const title = (source.frontmatter as { title?: unknown }).title;
|
|
16
|
+
return typeof title === "string" && title.trim() !== "" ? title : null;
|
|
17
|
+
}
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
* `services/consent.ts` stamps with — never from this app's copy of the
|
|
30
30
|
* registry. It is cached per process: fresh for 60 s, the last good entry
|
|
31
31
|
* served for up to 10 minutes while a refresh fails, at most one entry per
|
|
32
|
-
* enabled locale (SEC-8765-D).
|
|
33
|
-
*
|
|
32
|
+
* enabled locale (SEC-8765-D). The read carries this site's `AILK_SPOKE_KEY`
|
|
33
|
+
* (the api-client attaches it), which the hub counts against the key's own
|
|
34
|
+
* budget rather than this instance's egress IP (#8805). No usable entry →
|
|
35
|
+
* `503 AGREEMENTS_UNAVAILABLE`: no user is created without evidence. The database
|
|
34
36
|
* hooks run INSIDE the transaction and never call the network; they read the
|
|
35
37
|
* cache only (SEC-8765-I).
|
|
36
38
|
*
|
|
@@ -95,7 +95,26 @@
|
|
|
95
95
|
"careers": {
|
|
96
96
|
"heading": "Careers",
|
|
97
97
|
"emptyState": "Career listings coming soon.",
|
|
98
|
-
"applicationSuccess": "Thank you for applying! We have your application and will be in touch."
|
|
98
|
+
"applicationSuccess": "Thank you for applying! We have your application and will be in touch.",
|
|
99
|
+
"apply": "Apply for this role",
|
|
100
|
+
"openPositions": "Open positions",
|
|
101
|
+
"viewRole": "View role",
|
|
102
|
+
"employmentTypeFullTime": "Full-time",
|
|
103
|
+
"employmentTypePartTime": "Part-time",
|
|
104
|
+
"employmentTypeContractor": "Contract",
|
|
105
|
+
"employmentTypeTemporary": "Temporary",
|
|
106
|
+
"employmentTypeIntern": "Internship",
|
|
107
|
+
"employmentTypeVolunteer": "Volunteer",
|
|
108
|
+
"employmentTypePerDiem": "Per diem",
|
|
109
|
+
"employmentTypeOther": "Other",
|
|
110
|
+
"filterSearch": "Search roles",
|
|
111
|
+
"filterType": "Type",
|
|
112
|
+
"filterAllTypes": "All types",
|
|
113
|
+
"filterLocation": "Location",
|
|
114
|
+
"filterAllLocations": "All locations",
|
|
115
|
+
"filterEmpty": "No open positions match your search.",
|
|
116
|
+
"filterCountOne": "1 open position",
|
|
117
|
+
"filterCountOther": "{count} open positions"
|
|
99
118
|
},
|
|
100
119
|
"case-studies": {
|
|
101
120
|
"heading": "Case Studies",
|
|
@@ -127,7 +146,8 @@
|
|
|
127
146
|
"sendMessage": "Send message",
|
|
128
147
|
"successMessage": "Thank you for reaching out! We will be in touch shortly.",
|
|
129
148
|
"trackInAccount": "Track this in your account",
|
|
130
|
-
"consentStatement": "I accept the [Terms](terms)
|
|
149
|
+
"consentStatement": "I accept the [Terms](terms) and [Privacy Policy](privacy).",
|
|
150
|
+
"preferEmail": "Prefer email?",
|
|
131
151
|
"consentRequired": "Please accept the terms to send your message.",
|
|
132
152
|
"genericError": "Something went wrong, please try again.",
|
|
133
153
|
"unavailableHeading": "Get in touch",
|
|
@@ -145,7 +165,7 @@
|
|
|
145
165
|
"emailAck": "Got it.",
|
|
146
166
|
"emailPrompt": "What's the best email to reach you at?",
|
|
147
167
|
"consentAck": "Thanks — one last thing.",
|
|
148
|
-
"consentPrompt": "Do you accept our Terms
|
|
168
|
+
"consentPrompt": "Do you accept our Terms and Privacy Policy? They're linked at the foot of every page.",
|
|
149
169
|
"consentAccept": "Yes, I accept",
|
|
150
170
|
"consentDecline": "Not yet",
|
|
151
171
|
"skipMessage": "No worries — here's our form instead.",
|
|
@@ -185,6 +205,9 @@
|
|
|
185
205
|
"selectAllThatApply": "Select all that apply",
|
|
186
206
|
"getStarted": "Get Started",
|
|
187
207
|
"landingIntro": "A few short questions, then your result — it takes about a minute.",
|
|
208
|
+
"unavailableHeading": "This isn't available right now",
|
|
209
|
+
"unavailableBody": "Email us and we'll help.",
|
|
210
|
+
"unavailableNoEmail": "Please try again later.",
|
|
188
211
|
"bookCall": "Book a Call",
|
|
189
212
|
"emailMePlan": "Save plan",
|
|
190
213
|
"pairingTitle": "Better together",
|
|
@@ -265,6 +288,15 @@
|
|
|
265
288
|
"heading": "Pricing",
|
|
266
289
|
"emptyState": "Pricing page content coming soon."
|
|
267
290
|
},
|
|
291
|
+
"pricingOfferings": {
|
|
292
|
+
"cta": "Get started",
|
|
293
|
+
"recommended": "Recommended",
|
|
294
|
+
"recommendedForYou": "Recommended for you",
|
|
295
|
+
"addonsHeadline": "Add-ons",
|
|
296
|
+
"monthly": "{amount}/mo",
|
|
297
|
+
"annual": "{amount}/yr",
|
|
298
|
+
"oneTime": "{amount} one-time"
|
|
299
|
+
},
|
|
268
300
|
"purchasePolicy": {
|
|
269
301
|
"heading": "Purchase Policy",
|
|
270
302
|
"emptyState": "Purchase policy content coming soon."
|
|
@@ -335,6 +367,14 @@
|
|
|
335
367
|
"heading": "Book time with me",
|
|
336
368
|
"description": "Pick a time that works for you.",
|
|
337
369
|
"headline": "Pick a time that works",
|
|
370
|
+
"bookLength": "Book {minutes} min",
|
|
371
|
+
"meetingHeading": "Schedule a {title} meeting",
|
|
372
|
+
"meetingHeadingFallback": "Schedule a meeting",
|
|
373
|
+
"allMeetingTypes": "← All meeting types",
|
|
374
|
+
"backToSite": "Back",
|
|
375
|
+
"unavailableHeading": "Book a time by email",
|
|
376
|
+
"unavailableBody": "Email {email} to book a time.",
|
|
377
|
+
"unavailableNoEmail": "Online booking isn't available right now.",
|
|
338
378
|
"confirmed": {
|
|
339
379
|
"title": "You're booked",
|
|
340
380
|
"description": "Your call is confirmed and the calendar invite is on its way.",
|
|
@@ -384,7 +424,12 @@
|
|
|
384
424
|
},
|
|
385
425
|
"reports": {
|
|
386
426
|
"heading": "Reports",
|
|
387
|
-
"emptyState": "Reports coming soon."
|
|
427
|
+
"emptyState": "Reports coming soon.",
|
|
428
|
+
"gateIntro": "Tell us where to send it, and the report opens right away.",
|
|
429
|
+
"gateCta": "Get the report",
|
|
430
|
+
"openFile": "Open the report",
|
|
431
|
+
"fileByEmail": "The link is on its way to your email.",
|
|
432
|
+
"openExternal": "Open it"
|
|
388
433
|
},
|
|
389
434
|
"waitlistDashboard": {
|
|
390
435
|
"gate": {
|
|
@@ -1155,18 +1200,7 @@
|
|
|
1155
1200
|
"heading": "Features",
|
|
1156
1201
|
"emptyState": "Features coming soon."
|
|
1157
1202
|
},
|
|
1158
|
-
"newsletter": {
|
|
1159
|
-
"emailLabel": "Email",
|
|
1160
|
-
"submit": "Subscribe",
|
|
1161
|
-
"sending": "Subscribing…",
|
|
1162
|
-
"success": "You're subscribed. Thanks for signing up!",
|
|
1163
|
-
"consentStatement": "I accept the [Terms](terms)."
|
|
1164
|
-
},
|
|
1165
1203
|
"unsubscribe": {
|
|
1166
|
-
"title": "Unsubscribe",
|
|
1167
|
-
"lead": "Stop these emails?",
|
|
1168
|
-
"button": "Unsubscribe",
|
|
1169
|
-
"done": "You're unsubscribed from {description}.",
|
|
1170
1204
|
"already": "You were already unsubscribed.",
|
|
1171
1205
|
"invalid": "This link isn't valid.",
|
|
1172
1206
|
"unavailable": "Unsubscribing isn't available right now."
|
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
import { NextRequest, NextResponse } from "next/server";
|
|
27
27
|
import createMiddleware from "next-intl/middleware";
|
|
28
28
|
|
|
29
|
+
import { isBookingPath } from "./lib/auth-routes";
|
|
30
|
+
import { SCHEDULING_PAGES } from "./lib/modules.generated";
|
|
29
31
|
import {
|
|
30
32
|
DEFAULT_SECURITY_HEADERS,
|
|
33
|
+
SCHEDULING_SECURITY_HEADERS,
|
|
31
34
|
applyHeaders,
|
|
32
35
|
} from "./security-headers.config";
|
|
33
36
|
|
|
@@ -91,8 +94,15 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
91
94
|
// Step 2: run next-intl middleware (locale detection + rewrite/redirect)
|
|
92
95
|
const intlResponse = intlMiddleware(requestWithPathname) as NextResponse;
|
|
93
96
|
|
|
94
|
-
// Step 3: apply the 7 baseline security headers to the response
|
|
95
|
-
|
|
97
|
+
// Step 3: apply the 7 baseline security headers to the response. The booking
|
|
98
|
+
// pages of a site that carries the scheduling module get the same map with
|
|
99
|
+
// Cal.com's origin added to the CSP (#8843 D12); no other page does.
|
|
100
|
+
applyHeaders(
|
|
101
|
+
intlResponse.headers,
|
|
102
|
+
SCHEDULING_PAGES && isBookingPath(request.nextUrl.pathname, enabledLocales)
|
|
103
|
+
? SCHEDULING_SECURITY_HEADERS
|
|
104
|
+
: DEFAULT_SECURITY_HEADERS,
|
|
105
|
+
);
|
|
96
106
|
|
|
97
107
|
return intlResponse;
|
|
98
108
|
}
|
|
@@ -125,6 +125,23 @@ export function buildHeaders(
|
|
|
125
125
|
export const DEFAULT_SECURITY_HEADERS: Readonly<Record<string, string>> =
|
|
126
126
|
Object.freeze(buildHeaders());
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The booking pages' CSP additions (#8843 D12). The scheduling module's
|
|
130
|
+
* `scheduler-embed` section loads Cal.com's script and frames its booker, so
|
|
131
|
+
* these pages — and only these — name the provider origin. `'self'` in
|
|
132
|
+
* `frame-src` keeps same-origin frames working, which `default-src 'self'`
|
|
133
|
+
* allowed until the directive existed.
|
|
134
|
+
*/
|
|
135
|
+
export const SCHEDULING_CSP: CspDirectiveMap = Object.freeze({
|
|
136
|
+
"script-src": Object.freeze(["https://app.cal.com"]),
|
|
137
|
+
"connect-src": Object.freeze(["https://app.cal.com"]),
|
|
138
|
+
"frame-src": Object.freeze(["'self'", "https://app.cal.com"]),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** The header map for the booking pages: the default plus `SCHEDULING_CSP`. */
|
|
142
|
+
export const SCHEDULING_SECURITY_HEADERS: Readonly<Record<string, string>> =
|
|
143
|
+
Object.freeze(buildHeaders({ csp: SCHEDULING_CSP }));
|
|
144
|
+
|
|
128
145
|
/** Write every entry from a header map onto a Headers object. */
|
|
129
146
|
export function applyHeaders(
|
|
130
147
|
target: Headers,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: "BlogList"
|
|
3
3
|
slug: "blog"
|
|
4
|
-
title: "
|
|
5
|
-
description: "
|
|
4
|
+
title: "{Blog}"
|
|
5
|
+
description: "{One sentence on what you write about}"
|
|
6
6
|
sections:
|
|
7
7
|
[
|
|
8
8
|
{
|
|
@@ -13,75 +13,8 @@ sections:
|
|
|
13
13
|
"variant": "centered",
|
|
14
14
|
"gapBelow": "tight",
|
|
15
15
|
"eyebrow": "Blog",
|
|
16
|
-
"headline": "
|
|
17
|
-
"subheadline": "
|
|
18
|
-
"primaryCta":
|
|
19
|
-
{ "label": "Get Fumadocs", "href": "https://www.fumadocs.dev" },
|
|
20
|
-
"secondaryCta":
|
|
21
|
-
{
|
|
22
|
-
"label": "Upgrade to Pro",
|
|
23
|
-
"href": "https://ailaunchkit.ai/pricing",
|
|
24
|
-
"variant": "outline",
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
"id": "posts",
|
|
30
|
-
"type": "blog-list",
|
|
31
|
-
"data":
|
|
32
|
-
{
|
|
33
|
-
"variant": "grid",
|
|
34
|
-
"posts":
|
|
35
|
-
[
|
|
36
|
-
{
|
|
37
|
-
"title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
|
|
38
|
-
"slug": "post-1",
|
|
39
|
-
"href": "https://www.fumadocs.dev",
|
|
40
|
-
"excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
|
|
41
|
-
"publishedAt": "2026-01-01",
|
|
42
|
-
"readingTime": "5 min",
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
|
|
46
|
-
"slug": "post-2",
|
|
47
|
-
"href": "https://ailaunchkit.ai/pricing",
|
|
48
|
-
"excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
|
|
49
|
-
"publishedAt": "2026-01-02",
|
|
50
|
-
"readingTime": "5 min",
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
|
|
54
|
-
"slug": "post-3",
|
|
55
|
-
"href": "https://www.fumadocs.dev",
|
|
56
|
-
"excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
|
|
57
|
-
"publishedAt": "2026-01-03",
|
|
58
|
-
"readingTime": "5 min",
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
"title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
|
|
62
|
-
"slug": "post-4",
|
|
63
|
-
"href": "https://ailaunchkit.ai/pricing",
|
|
64
|
-
"excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
|
|
65
|
-
"publishedAt": "2026-01-04",
|
|
66
|
-
"readingTime": "5 min",
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
"title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
|
|
70
|
-
"slug": "post-5",
|
|
71
|
-
"href": "https://www.fumadocs.dev",
|
|
72
|
-
"excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
|
|
73
|
-
"publishedAt": "2026-01-05",
|
|
74
|
-
"readingTime": "5 min",
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
|
|
78
|
-
"slug": "post-6",
|
|
79
|
-
"href": "https://ailaunchkit.ai/pricing",
|
|
80
|
-
"excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
|
|
81
|
-
"publishedAt": "2026-01-06",
|
|
82
|
-
"readingTime": "5 min",
|
|
83
|
-
},
|
|
84
|
-
],
|
|
16
|
+
"headline": "{Blog}",
|
|
17
|
+
"subheadline": "{One sentence on what you write about}",
|
|
85
18
|
},
|
|
86
19
|
},
|
|
87
20
|
{
|
|
@@ -20,46 +20,6 @@ sections:
|
|
|
20
20
|
"primaryCta": { "label": "{Get in touch}", "href": "/contact" },
|
|
21
21
|
},
|
|
22
22
|
},
|
|
23
|
-
{
|
|
24
|
-
"id": "add-scheduling",
|
|
25
|
-
"type": "cta",
|
|
26
|
-
"data":
|
|
27
|
-
{
|
|
28
|
-
"variant": "box",
|
|
29
|
-
"headline": "{What booking a call would do for your visitors}",
|
|
30
|
-
"description": "{One or two sentences on what live scheduling adds once it's on}",
|
|
31
|
-
"primaryCta":
|
|
32
|
-
{
|
|
33
|
-
"label": "Add scheduling with Pro",
|
|
34
|
-
"href": "https://ailaunchkit.ai/pricing",
|
|
35
|
-
"external": true,
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"id": "what-happens-on-the-call",
|
|
41
|
-
"type": "items",
|
|
42
|
-
"data":
|
|
43
|
-
{
|
|
44
|
-
"headline": "What happens on the call?",
|
|
45
|
-
"columns": 3,
|
|
46
|
-
"items":
|
|
47
|
-
[
|
|
48
|
-
{
|
|
49
|
-
"title": "{The first thing you cover}",
|
|
50
|
-
"description": "{One or two sentences on what the visitor learns or decides here}",
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"title": "{The second thing you cover}",
|
|
54
|
-
"description": "{One or two sentences on what the visitor learns or decides here}",
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"title": "{The next step you agree}",
|
|
58
|
-
"description": "{One or two sentences on what happens after the call}",
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
23
|
{
|
|
64
24
|
"id": "back-to-meet",
|
|
65
25
|
"type": "content-block",
|
|
@@ -77,4 +37,4 @@ sections:
|
|
|
77
37
|
]
|
|
78
38
|
---
|
|
79
39
|
|
|
80
|
-
{/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A
|
|
40
|
+
{/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A site that carries the scheduling module renders the hub's meeting page instead, reading this seed only for its URL, its JSON-LD and its fallback title (#8843 C5). Placeholder copy — replace every {brace} with your own words before you launch. */}
|