create-ailk 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contactFormFromConfig — a Contact form magnet's config, as the shared
|
|
3
|
+
* `LeadCaptureForm` block renders it (#8661 D6).
|
|
4
|
+
*
|
|
5
|
+
* Reads the config's first step, which must be a `field` step (a config that
|
|
6
|
+
* opens with any other step is not a contact form). Answers `null` unless that
|
|
7
|
+
* step holds fields named `name` and `email`, because `captureLeadAction` needs both;
|
|
8
|
+
* the page then falls back (section fields, else the built-in config).
|
|
9
|
+
*
|
|
10
|
+
* Each `text`, `email`, `tel`, `url`, `textarea` or `select` field is mapped,
|
|
11
|
+
* in order. `name` and `email` sit side by side (`colSpan: 1`, the built-in
|
|
12
|
+
* pairing); every other field runs full width. The step's first `consent`
|
|
13
|
+
* field is the terms statement (its label), and its `copy.cta` the submit
|
|
14
|
+
* label. Other `consent` fields and `address` fields are not rendered.
|
|
15
|
+
*
|
|
16
|
+
* Copy resolves with the one LocalizedString rule every flow surface shares
|
|
17
|
+
* (`resolveLocalizedString`). Pure: no fetch, no React.
|
|
18
|
+
*/
|
|
19
|
+
import type { ContactFormField } from "@working-theory/ui";
|
|
20
|
+
import { resolveLocalizedString, type FlowConfig } from "@working-theory/validation";
|
|
21
|
+
|
|
22
|
+
export interface ContactFormView {
|
|
23
|
+
fields: ContactFormField[];
|
|
24
|
+
/** The step's first consent field's label — `[Label](key)` tokens intact. */
|
|
25
|
+
consentStatement?: string;
|
|
26
|
+
/** The step's `copy.cta`. */
|
|
27
|
+
submitLabel?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
|
|
31
|
+
type FieldDef = FieldStep["fields"][number];
|
|
32
|
+
|
|
33
|
+
const RENDERED_TYPES = ["text", "email", "tel", "url", "textarea", "select"] as const;
|
|
34
|
+
type RenderedType = (typeof RENDERED_TYPES)[number];
|
|
35
|
+
|
|
36
|
+
function isRendered(type: FieldDef["inputType"]): type is RenderedType {
|
|
37
|
+
return (RENDERED_TYPES as readonly string[]).includes(type);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function contactFormFromConfig(
|
|
41
|
+
config: FlowConfig,
|
|
42
|
+
locale: string,
|
|
43
|
+
): ContactFormView | null {
|
|
44
|
+
const step = config.steps[0];
|
|
45
|
+
if (step?.type !== "field") return null;
|
|
46
|
+
const names = new Set(step.fields.map((f) => f.name));
|
|
47
|
+
if (!names.has("name") || !names.has("email")) return null;
|
|
48
|
+
|
|
49
|
+
const resolve = (value: Record<string, string>): string =>
|
|
50
|
+
resolveLocalizedString(value, locale, config.defaultLocale);
|
|
51
|
+
|
|
52
|
+
const fields: ContactFormField[] = [];
|
|
53
|
+
for (const field of step.fields) {
|
|
54
|
+
if (!isRendered(field.inputType)) continue;
|
|
55
|
+
fields.push({
|
|
56
|
+
name: field.name,
|
|
57
|
+
type: field.inputType,
|
|
58
|
+
label: resolve(field.copy.label),
|
|
59
|
+
required: field.required ?? false,
|
|
60
|
+
...(field.copy.placeholder !== undefined && {
|
|
61
|
+
placeholder: resolve(field.copy.placeholder),
|
|
62
|
+
}),
|
|
63
|
+
...(field.copy.help !== undefined && { helpText: resolve(field.copy.help) }),
|
|
64
|
+
...(field.options !== undefined && {
|
|
65
|
+
options: field.options.map((o) => ({ value: o.value, label: resolve(o.label) })),
|
|
66
|
+
}),
|
|
67
|
+
colSpan: field.name === "name" || field.name === "email" ? 1 : 2,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const consent = step.fields.find((f) => f.inputType === "consent");
|
|
72
|
+
return {
|
|
73
|
+
fields,
|
|
74
|
+
...(consent !== undefined && { consentStatement: resolve(consent.copy.label) }),
|
|
75
|
+
...(step.copy.cta !== undefined && { submitLabel: resolve(step.copy.cta) }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -20,6 +20,7 @@ type ContactTextKey =
|
|
|
20
20
|
| "successMessage"
|
|
21
21
|
| "genericError"
|
|
22
22
|
| "consentStatement"
|
|
23
|
+
| "consentRequired"
|
|
23
24
|
| "unavailableHeading"
|
|
24
25
|
| "unavailableBody"
|
|
25
26
|
| "unavailableNoEmail";
|
|
@@ -43,6 +44,7 @@ export function contactFormText(t: ContactTranslator): LeadCaptureFormText {
|
|
|
43
44
|
success: t("successMessage"),
|
|
44
45
|
genericError: t("genericError"),
|
|
45
46
|
consentStatement: t("consentStatement"),
|
|
47
|
+
consentRequired: t("consentRequired"),
|
|
46
48
|
unavailableHeading: t("unavailableHeading"),
|
|
47
49
|
unavailableBody: t("unavailableBody", KEEP_EMAIL_PLACEHOLDER),
|
|
48
50
|
unavailableNoEmail: t("unavailableNoEmail"),
|
|
@@ -10,13 +10,26 @@
|
|
|
10
10
|
* Canonical filename: contact.actions.ts (*.actions.ts per ADR 0013 / U0 spec
|
|
11
11
|
* parity-test regex `ACTION_FILE_RE = /\.actions\.ts$/`).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* The honeypot is checked here, and so is a per-visitor limit (#8747): 30
|
|
14
|
+
* calls per 10 minutes per visitor address, before the write. From a server
|
|
15
|
+
* action the hub sees this site's egress address, so one visitor could
|
|
16
|
+
* otherwise drain the hub's per-IP budget for every visitor of the site. With
|
|
17
|
+
* no readable address the limit is skipped, never shared. The hub keeps its
|
|
18
|
+
* own per-IP limit on the route.
|
|
19
|
+
*
|
|
20
|
+
* The write (#8747): every lead goes through `writeLead`, the site's lead
|
|
21
|
+
* writer (`src/lib/lead-write.ts`), which marks it `payload.site =
|
|
22
|
+
* AILK_LEAD_SITE` and `payload.page = "contact"` (spread last, so no form field
|
|
23
|
+
* can override them) and sends no `siteId` — the API names the workspace
|
|
24
|
+
* itself, from the spoke key on a hub or its own site alias otherwise.
|
|
14
25
|
*
|
|
15
26
|
* Outcome (#6730 D8): the API's 503 `lead_capture_not_configured` — this
|
|
16
27
|
* deployment has nowhere to put a lead — maps to `{ kind: "unavailable" }`,
|
|
17
|
-
* which the shared `LeadCaptureForm` renders as the site's contact email.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
28
|
+
* which the shared `LeadCaptureForm` renders as the site's contact email. So
|
|
29
|
+
* does an API that cannot be reached (#8747): a thrown submit, a 502 or a 504
|
|
30
|
+
* — the message reached nobody, so the visitor is shown the email rather than
|
|
31
|
+
* a "try again". Every other non-2xx is the generic `error`; only a 2xx is
|
|
32
|
+
* ever `success`.
|
|
20
33
|
*
|
|
21
34
|
* Extra fields (#6730 D8a): a `contact-form` section can declare fields
|
|
22
35
|
* beyond name/email/message/phone (a `select`, say). Every other string
|
|
@@ -33,8 +46,25 @@
|
|
|
33
46
|
* second `*Action`-suffixed export, which would trip
|
|
34
47
|
* `apps/mcp/__tests__/parity.test.ts`'s Server-Action↔MCP-tool 1:1 count for
|
|
35
48
|
* a capability (lead capture) this repo already has an MCP twin for
|
|
36
|
-
* (`ailkCaptureLead`). Absent (the real `<form>` never sets it) →
|
|
37
|
-
*
|
|
49
|
+
* (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → the
|
|
50
|
+
* built-in form's one standard source, `"contact-form"` (#8747; it was
|
|
51
|
+
* `"web"`, and the sites disagreed).
|
|
52
|
+
*
|
|
53
|
+
* Source allow-list and terms (#8661 D8, #8730): `/contact` stamps a
|
|
54
|
+
* submission it drew from the Contact form magnet with a hidden `source` of
|
|
55
|
+
* `"flow:contact"`, so it lands as an Inbox submission of that magnet (it
|
|
56
|
+
* reaches the Needs-you queue through the submissions read, not the message
|
|
57
|
+
* list — `flow:<slug>` is never a message source). The field is
|
|
58
|
+
* visitor-controlled, so only the contact sources (`CONTACT_LEAD_SOURCES`:
|
|
59
|
+
* `"contact-form"`, `"conversational-intake"`, `"flow:contact"`) are kept;
|
|
60
|
+
* anything else is sent as `"contact-form"`, a message source, so no
|
|
61
|
+
* visitor-set value drops a real message out of Needs-you. The source picks
|
|
62
|
+
* the stamp and never the gate (#8730 D2): EVERY post must carry
|
|
63
|
+
* `consent=true` — the form's terms box, or the intake's own terms turn — or
|
|
64
|
+
* it answers a `consent` field error, merged with the schema's, and nothing
|
|
65
|
+
* is sent. An accepted post tells the API so (`consent: true`, which the API
|
|
66
|
+
* requires of a contact lead and records on it); an API `400
|
|
67
|
+
* consent_required` is shown as the same field error.
|
|
38
68
|
*
|
|
39
69
|
* Visitor forwarding (#7394): on a spoke whose API is a hub, the call
|
|
40
70
|
* carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
|
|
@@ -43,16 +73,53 @@
|
|
|
43
73
|
* of the spoke host's. No key, no client address, or headers that cannot be
|
|
44
74
|
* read → the call is made with one argument, exactly as before; a failure to
|
|
45
75
|
* read headers never blocks a submission.
|
|
76
|
+
*
|
|
77
|
+
* The account link (#8757 D8): after a successful write, the API's 201 may
|
|
78
|
+
* carry `next` (D6). `{ completion: "invite" }` — the magnet invites the
|
|
79
|
+
* submitter to their account — answers `{ kind: "success", next: { href,
|
|
80
|
+
* label } }`, the href from `accountInviteHref` (the request's locale, the
|
|
81
|
+
* submitter's email, this site's sign-in provider) and the label the
|
|
82
|
+
* `contact.trackInAccount` message. Anything else — no `next`, a site with no
|
|
83
|
+
* sign-in, an email the hint schema refuses, a locale or message that cannot
|
|
84
|
+
* be read — answers exactly `{ kind: "success" }`: a written lead is never
|
|
85
|
+
* reported as a failure.
|
|
86
|
+
*
|
|
87
|
+
* Continue to checkout (#8757 D11): `{ completion: "checkout",
|
|
88
|
+
* checkoutOffering }` — the magnet sends the submitter on to the offering's
|
|
89
|
+
* standard checkout — calls `redirect("/api/checkout/<checkoutOffering>")`
|
|
90
|
+
* (a Server Action redirect, a 303). It runs only after the write resolved
|
|
91
|
+
* `ok`, and outside any `try`, since `redirect()` throws NEXT_REDIRECT by
|
|
92
|
+
* design (`checkout.actions.ts`'s rule). The key is re-checked with
|
|
93
|
+
* `flowCheckoutOfferingSchema` first; one that fails answers `{ kind:
|
|
94
|
+
* "success" }`, as `lead` does. The standard checkout is served on a spoke
|
|
95
|
+
* only (`TOPOLOGY`), so any other build answers `{ kind: "success" }` too,
|
|
96
|
+
* never a redirect to a 404. A failed write never redirects.
|
|
46
97
|
*/
|
|
47
98
|
|
|
48
|
-
import {
|
|
99
|
+
import { visitorForwardHeaders } from "@working-theory/api-client";
|
|
49
100
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
50
|
-
import {
|
|
101
|
+
import {
|
|
102
|
+
CONTACT_LEAD_SOURCES,
|
|
103
|
+
flowCheckoutOfferingSchema,
|
|
104
|
+
leadSubmitNextSchema,
|
|
105
|
+
submitLeadInputSchema,
|
|
106
|
+
} from "@working-theory/validation";
|
|
51
107
|
import { cookies, headers } from "next/headers";
|
|
108
|
+
import { redirect } from "next/navigation";
|
|
109
|
+
import { getLocale, getTranslations } from "next-intl/server";
|
|
52
110
|
|
|
111
|
+
import { accountInviteHref } from "../../../lib/account-invite";
|
|
53
112
|
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
113
|
+
import { TOPOLOGY } from "../../../lib/topology.generated";
|
|
114
|
+
import { writeLead } from "../../../src/lib/lead-write";
|
|
115
|
+
import { checkRateLimit, clientIpFromHeaders } from "../../../src/lib/rate-limit";
|
|
54
116
|
|
|
55
117
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
118
|
+
const RATE_LIMITED = "Too many messages from here. Please try again in a few minutes.";
|
|
119
|
+
|
|
120
|
+
/** The per-visitor budget (#8747): the consumer sites' own values. */
|
|
121
|
+
const RATE_LIMIT_MAX_CALLS = 30;
|
|
122
|
+
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
56
123
|
|
|
57
124
|
/** Field names the action reads itself — never copied into `payload`. */
|
|
58
125
|
const RESERVED_FIELDS = new Set([
|
|
@@ -65,6 +132,11 @@ const RESERVED_FIELDS = new Set([
|
|
|
65
132
|
"source",
|
|
66
133
|
]);
|
|
67
134
|
const MAX_EXTRA_FIELDS = 20;
|
|
135
|
+
|
|
136
|
+
/** The built-in form's source (#8747), and the one an unlisted value becomes. */
|
|
137
|
+
const DEFAULT_SOURCE = "contact-form";
|
|
138
|
+
/** The server's terms-error text. The form block shows its own copy for this key. */
|
|
139
|
+
const CONSENT_REQUIRED = "Please accept the terms to send your message.";
|
|
68
140
|
const MAX_EXTRA_FIELD_LENGTH = 2000;
|
|
69
141
|
|
|
70
142
|
/**
|
|
@@ -85,6 +157,16 @@ function extraFields(formData: FormData): Record<string, string> | null {
|
|
|
85
157
|
return extra;
|
|
86
158
|
}
|
|
87
159
|
|
|
160
|
+
/** A 400 whose body names `consent_required` (#8730). */
|
|
161
|
+
function isConsentRequired(res: { status: number; body: unknown }): boolean {
|
|
162
|
+
return (
|
|
163
|
+
res.status === 400 &&
|
|
164
|
+
typeof res.body === "object" &&
|
|
165
|
+
res.body !== null &&
|
|
166
|
+
(res.body as { error?: unknown }).error === "consent_required"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
88
170
|
/** A 503 whose body names `lead_capture_not_configured`. */
|
|
89
171
|
function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
90
172
|
return (
|
|
@@ -95,6 +177,51 @@ function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
|
95
177
|
);
|
|
96
178
|
}
|
|
97
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The API could not be reached (#8747): `writeLead`'s `status: 0` (a thrown
|
|
182
|
+
* submit) or a gateway failure. A 503 that is not `lead_capture_not_configured`
|
|
183
|
+
* stays the generic error — the API answered.
|
|
184
|
+
*/
|
|
185
|
+
function isApiUnreachable(res: { status: number }): boolean {
|
|
186
|
+
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* #8757 D11 — the offering a `checkout` answer names, when its key passes the
|
|
191
|
+
* schema and this build serves the standard checkout (a spoke); else `null`.
|
|
192
|
+
*/
|
|
193
|
+
function checkoutOfferingOf(next: unknown): string | null {
|
|
194
|
+
if (TOPOLOGY !== "spoke") return null;
|
|
195
|
+
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
196
|
+
if (!parsed.success || parsed.data.completion !== "checkout") return null;
|
|
197
|
+
const key = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
|
|
198
|
+
return key.success ? key.data : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* #8757 D8 — the success state for a written lead: with the account link when
|
|
203
|
+
* the API answered `next: { completion: "invite" }` and this site can build
|
|
204
|
+
* one, else exactly `{ kind: "success" }`. Never throws.
|
|
205
|
+
*/
|
|
206
|
+
async function successState(next: unknown, email: string): Promise<LeadCaptureFormState> {
|
|
207
|
+
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
208
|
+
if (!parsed.success || parsed.data.completion !== "invite") return { kind: "success" };
|
|
209
|
+
let locale = "en";
|
|
210
|
+
try {
|
|
211
|
+
locale = await getLocale();
|
|
212
|
+
} catch {
|
|
213
|
+
// No request-scoped locale — the default locale's link.
|
|
214
|
+
}
|
|
215
|
+
const href = accountInviteHref(locale, email);
|
|
216
|
+
if (href === null) return { kind: "success" };
|
|
217
|
+
try {
|
|
218
|
+
const t = await getTranslations("contact");
|
|
219
|
+
return { kind: "success", next: { href, label: t("trackInAccount") } };
|
|
220
|
+
} catch {
|
|
221
|
+
return { kind: "success" };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
98
225
|
export async function captureLeadAction(
|
|
99
226
|
_prevState: LeadCaptureFormState,
|
|
100
227
|
formData: FormData,
|
|
@@ -112,15 +239,27 @@ export async function captureLeadAction(
|
|
|
112
239
|
phone: formData.get("phone") ?? undefined,
|
|
113
240
|
};
|
|
114
241
|
|
|
242
|
+
const posted = formData.get("source");
|
|
243
|
+
const source =
|
|
244
|
+
typeof posted === "string" && CONTACT_LEAD_SOURCES.has(posted) ? posted : DEFAULT_SOURCE;
|
|
245
|
+
|
|
246
|
+
// #8730 D2 — every source, the intake's included: it asks for the terms
|
|
247
|
+
// itself and posts consent=true like the form does.
|
|
248
|
+
const fieldErrors: Record<string, string> = {};
|
|
249
|
+
if (formData.get("consent") !== "true") {
|
|
250
|
+
fieldErrors.consent = CONSENT_REQUIRED;
|
|
251
|
+
}
|
|
252
|
+
|
|
115
253
|
const parsed = submitLeadInputSchema.safeParse(raw);
|
|
116
254
|
if (!parsed.success) {
|
|
117
|
-
const fieldErrors: Record<string, string> = {};
|
|
118
255
|
for (const issue of parsed.error.issues) {
|
|
119
256
|
const field = String(issue.path[0] ?? "form");
|
|
120
257
|
if (!fieldErrors[field]) {
|
|
121
258
|
fieldErrors[field] = issue.message;
|
|
122
259
|
}
|
|
123
260
|
}
|
|
261
|
+
}
|
|
262
|
+
if (!parsed.success || Object.keys(fieldErrors).length > 0) {
|
|
124
263
|
return { kind: "error", fieldErrors };
|
|
125
264
|
}
|
|
126
265
|
|
|
@@ -128,29 +267,35 @@ export async function captureLeadAction(
|
|
|
128
267
|
if (extra === null) return { kind: "error", message: GENERIC_ERROR };
|
|
129
268
|
|
|
130
269
|
const { name, email, message, phone } = parsed.data;
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
email,
|
|
137
|
-
payload: {
|
|
138
|
-
...extra,
|
|
139
|
-
name,
|
|
140
|
-
...(message !== undefined && { message }),
|
|
141
|
-
...(phone !== undefined && { phone }),
|
|
142
|
-
},
|
|
270
|
+
const payload = {
|
|
271
|
+
...extra,
|
|
272
|
+
name,
|
|
273
|
+
...(message !== undefined && { message }),
|
|
274
|
+
...(phone !== undefined && { phone }),
|
|
143
275
|
};
|
|
144
276
|
|
|
145
|
-
let
|
|
277
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
146
278
|
try {
|
|
147
|
-
|
|
148
|
-
await headers(),
|
|
149
|
-
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
150
|
-
);
|
|
151
|
-
if (forwarded) init = { headers: forwarded };
|
|
279
|
+
requestHeaders = await headers();
|
|
152
280
|
} catch {
|
|
153
|
-
// No request scope to read —
|
|
281
|
+
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// No readable address: skip the per-visitor limit rather than put every
|
|
285
|
+
// visitor in one shared bucket a single caller could fill. The hub's own
|
|
286
|
+
// per-IP limit still applies.
|
|
287
|
+
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
288
|
+
if (
|
|
289
|
+
visitor !== "unknown" &&
|
|
290
|
+
!checkRateLimit(`contact:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
291
|
+
) {
|
|
292
|
+
return { kind: "error", message: RATE_LIMITED };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
296
|
+
if (requestHeaders) {
|
|
297
|
+
const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
|
|
298
|
+
if (forwarded) init = { headers: forwarded };
|
|
154
299
|
}
|
|
155
300
|
|
|
156
301
|
// #8438 D12 — the banner's visitor id, so a later form submit links its
|
|
@@ -163,20 +308,18 @@ export async function captureLeadAction(
|
|
|
163
308
|
// No request scope to read — submit with no visitor-link header.
|
|
164
309
|
}
|
|
165
310
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
311
|
+
// writeLead never throws: a thrown submit is logged there by error name
|
|
312
|
+
// only, with no field values (#4042), and resolves as `status: 0`.
|
|
313
|
+
const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
|
|
314
|
+
if (!res.ok) {
|
|
315
|
+
if (isConsentRequired(res)) {
|
|
316
|
+
return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
171
317
|
}
|
|
172
|
-
return { kind: "
|
|
173
|
-
} catch (err) {
|
|
174
|
-
// Log server-side for observability (#4042, sibling of flow.actions.ts's
|
|
175
|
-
// #3989 fix). The api-client's post() never attaches the request payload
|
|
176
|
-
// to a thrown error (packages/api-client/src/http.ts), so logging `err`
|
|
177
|
-
// itself is safe — no submitted form fields (name/email/message/phone)
|
|
178
|
-
// are included.
|
|
179
|
-
console.error("[captureLeadAction]", err);
|
|
318
|
+
if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
|
|
180
319
|
return { kind: "error", message: GENERIC_ERROR };
|
|
181
320
|
}
|
|
321
|
+
// #8757 D11 — after the write, outside any try: redirect() throws by design.
|
|
322
|
+
const checkoutOffering = checkoutOfferingOf(res.body.next);
|
|
323
|
+
if (checkoutOffering !== null) redirect(`/api/checkout/${checkoutOffering}`);
|
|
324
|
+
return successState(res.body.next, email);
|
|
182
325
|
}
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* ids ARE the submission adapter's field-mapping contract; renaming a
|
|
12
12
|
* question id here requires updating that mapping too.
|
|
13
13
|
*
|
|
14
|
+
* A fourth, closed question (`consent`, #8730 D2) asks for the terms before
|
|
15
|
+
* anything is sent. The engine records a tapped pill as its LABEL, so the
|
|
16
|
+
* wrapper treats the answer as acceptance only when it equals
|
|
17
|
+
* `copy.consentAccept` exactly; any other answer — the decline pill or typed
|
|
18
|
+
* text — sends nothing and shows the form, whose own terms box decides.
|
|
19
|
+
*
|
|
14
20
|
* Copy is translated (next-intl "contact" namespace, "intake.*" keys —
|
|
15
21
|
* see apps/web/messages/en.json) rather than hardcoded, consistent with
|
|
16
22
|
* ContactForm's own `useTranslations("contact")` usage. The wrapper resolves
|
|
@@ -31,10 +37,18 @@ export interface ContactIntakeCopy {
|
|
|
31
37
|
messagePrompt: string;
|
|
32
38
|
emailAck: string;
|
|
33
39
|
emailPrompt: string;
|
|
40
|
+
consentAck: string;
|
|
41
|
+
consentPrompt: string;
|
|
42
|
+
/** The accept pill's label — the one answer that is terms acceptance. */
|
|
43
|
+
consentAccept: string;
|
|
44
|
+
consentDecline: string;
|
|
34
45
|
skipMessage: string;
|
|
35
46
|
completionMessage: string;
|
|
36
47
|
}
|
|
37
48
|
|
|
49
|
+
/** The accept option's value — its pill's test id is `…-pill-accept`. */
|
|
50
|
+
const INTAKE_CONSENT_ACCEPT = "accept";
|
|
51
|
+
|
|
38
52
|
export function buildContactIntakeScript(
|
|
39
53
|
copy: ContactIntakeCopy,
|
|
40
54
|
): IntakeScript {
|
|
@@ -59,6 +73,18 @@ export function buildContactIntakeScript(
|
|
|
59
73
|
acknowledgement: copy.emailAck,
|
|
60
74
|
prompt: copy.emailPrompt,
|
|
61
75
|
},
|
|
76
|
+
// #8730 D2 — the intake asks for the terms itself. Only the accept
|
|
77
|
+
// pill's label is acceptance; the wrapper reads `consent` by that id.
|
|
78
|
+
{
|
|
79
|
+
id: "consent",
|
|
80
|
+
kind: "closed",
|
|
81
|
+
acknowledgement: copy.consentAck,
|
|
82
|
+
prompt: copy.consentPrompt,
|
|
83
|
+
options: [
|
|
84
|
+
{ value: INTAKE_CONSENT_ACCEPT, label: copy.consentAccept },
|
|
85
|
+
{ value: "decline", label: copy.consentDecline },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
62
88
|
],
|
|
63
89
|
skip: { message: copy.skipMessage },
|
|
64
90
|
completion: { message: copy.completionMessage },
|
|
@@ -11,10 +11,31 @@
|
|
|
11
11
|
// API, an older API without the status route, or any other failed read is
|
|
12
12
|
// treated as accepting: the form renders, and its submit reports honestly.
|
|
13
13
|
//
|
|
14
|
+
// The form's fields (#8661 D6), first usable wins: the workspace's Contact
|
|
15
|
+
// form magnet (`flows.get("contact")`, the public flow read — the API resolves
|
|
16
|
+
// the workspace, the site alias keyless and a spoke by its key); else the
|
|
17
|
+
// page's `contact-form` section fields when it declares any; else the
|
|
18
|
+
// built-in form: the seeded magnet's own config, its name, email and message
|
|
19
|
+
// labels from the `contact` catalog. The submit label is the magnet's, else
|
|
20
|
+
// the section's; the success message is the section's. A magnet-driven
|
|
21
|
+
// post is stamped `source: "flow:contact"`, so it is an Inbox submission of
|
|
22
|
+
// the Contact form; the other two post as `contact-form` (#8747). The page is rendered on
|
|
23
|
+
// every request, but its two hub reads (the magnet and the capture status) are
|
|
24
|
+
// shared across requests for HUB_READ_TTL_MS (#8731): a visitor view carries no
|
|
25
|
+
// forwarding headers, so on a spoke every view would otherwise count against
|
|
26
|
+
// the spoke server's one per-IP limit at the hub. An Admin edit shows within
|
|
27
|
+
// that window. The terms statement
|
|
28
|
+
// links Terms, Privacy Policy and Working Agreement from a page-built map —
|
|
29
|
+
// a magnet label can name a link key, never an href.
|
|
30
|
+
//
|
|
31
|
+
// A `contact-form` section in the `column` variant (the minimalism Contact
|
|
32
|
+
// page, #8661 D12) gets the stacked form look and a Send that stays enabled,
|
|
33
|
+
// gating the terms box on submit instead.
|
|
34
|
+
//
|
|
14
35
|
// The chat-style intake (ConversationalIntakeWrapper, #4534) remains
|
|
15
36
|
// available to a site that prefers it. JSON-LD and translations handled by
|
|
16
37
|
// renderPage.
|
|
17
|
-
import { leads } from "@working-theory/api-client";
|
|
38
|
+
import { flows, leads } from "@working-theory/api-client";
|
|
18
39
|
import type {
|
|
19
40
|
getMdxSource as defaultGetMdxSource,
|
|
20
41
|
getPageSections as defaultGetPageSections,
|
|
@@ -29,17 +50,26 @@ import {
|
|
|
29
50
|
ContactFallback,
|
|
30
51
|
type ContactFormField,
|
|
31
52
|
LeadCaptureForm,
|
|
53
|
+
type LeadCaptureFormText,
|
|
32
54
|
localeHref,
|
|
33
55
|
} from "@working-theory/ui";
|
|
56
|
+
import {
|
|
57
|
+
CONTACT_FORM_FLOW_SLUG,
|
|
58
|
+
contactFormFlowConfig,
|
|
59
|
+
flowLeadSource,
|
|
60
|
+
type FlowConfig,
|
|
61
|
+
} from "@working-theory/validation";
|
|
34
62
|
import type { Metadata } from "next";
|
|
35
63
|
import { getTranslations } from "next-intl/server";
|
|
36
64
|
import type { ReactElement } from "react";
|
|
37
65
|
|
|
66
|
+
import { contactFormFromConfig } from "./contact-form-magnet";
|
|
38
67
|
import { contactFormText } from "./contact-form-text";
|
|
39
68
|
import { captureLeadAction } from "./contact.actions";
|
|
40
69
|
import { readSiteContactEmail } from "./site-contact-email";
|
|
41
70
|
|
|
42
|
-
|
|
71
|
+
// #8661 D6 — rendered on every request; the magnet read behind it is shared for HUB_READ_TTL_MS (#8731).
|
|
72
|
+
export const dynamic = "force-dynamic";
|
|
43
73
|
|
|
44
74
|
interface Props {
|
|
45
75
|
params: Promise<{ locale: string }>;
|
|
@@ -72,14 +102,72 @@ function sectionFields(section: ContactFormSection): ContactFormField[] {
|
|
|
72
102
|
}));
|
|
73
103
|
}
|
|
74
104
|
|
|
105
|
+
const BUILT_IN_LABELS = { name: "nameLabel", email: "emailLabel", message: "messageLabel" } as const;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The built-in form: the seeded magnet's own fields, with the labels the
|
|
109
|
+
* `contact` catalog carries (name, email, message) taken from it, so a
|
|
110
|
+
* translated site keeps its translated form when the magnet is absent.
|
|
111
|
+
*/
|
|
112
|
+
function builtInFields(text: LeadCaptureFormText, locale: string): ContactFormField[] {
|
|
113
|
+
const view = contactFormFromConfig(contactFormFlowConfig, locale);
|
|
114
|
+
return (view?.fields ?? []).map((f) => {
|
|
115
|
+
const key = BUILT_IN_LABELS[f.name as keyof typeof BUILT_IN_LABELS];
|
|
116
|
+
return key ? { ...f, label: text[key] } : f;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* How long the two hub reads are shared across requests (#8731). Short, so an
|
|
122
|
+
* Admin edit to the magnet shows within seconds; long enough that a burst of
|
|
123
|
+
* views makes one read of each, not one per view.
|
|
124
|
+
*/
|
|
125
|
+
const HUB_READ_TTL_MS = 15_000;
|
|
126
|
+
|
|
127
|
+
const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `read()`'s result, shared per instance for HUB_READ_TTL_MS. Concurrent calls
|
|
131
|
+
* share one in-flight read. A read that settles to `null` (any failed read,
|
|
132
|
+
* see the two readers below) is shared the same way, so a burst against an
|
|
133
|
+
* unreachable hub is not a burst of retries.
|
|
134
|
+
*/
|
|
135
|
+
function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const hit = hubReads.get(key);
|
|
138
|
+
if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
|
|
139
|
+
const fresh = read();
|
|
140
|
+
hubReads.set(key, { at: now, read: fresh });
|
|
141
|
+
return fresh;
|
|
142
|
+
}
|
|
143
|
+
|
|
75
144
|
/** The API's capture status, or `null` for any failed read (see header). */
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
145
|
+
function readCaptureStatus(): Promise<CaptureStatus | null> {
|
|
146
|
+
return sharedHubRead("capture-status", async () => {
|
|
147
|
+
try {
|
|
148
|
+
const res = await leads.captureStatus();
|
|
149
|
+
return res.ok && res.body.ok === true ? res.body.data : null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The served Contact form magnet's config, or `null` for any failed read: a
|
|
158
|
+
* non-2xx (absent, draft, a build with no flow route) or a throw. The
|
|
159
|
+
* property access sits inside the `try`, so a client with no `flows` is a
|
|
160
|
+
* failed read too.
|
|
161
|
+
*/
|
|
162
|
+
function readContactMagnet(): Promise<FlowConfig | null> {
|
|
163
|
+
return sharedHubRead("contact-magnet", async () => {
|
|
164
|
+
try {
|
|
165
|
+
const res = await flows.get(CONTACT_FORM_FLOW_SLUG);
|
|
166
|
+
return res.ok ? res.body : null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
83
171
|
}
|
|
84
172
|
|
|
85
173
|
export async function generateMetadata({
|
|
@@ -95,9 +183,10 @@ export default async function Page({
|
|
|
95
183
|
getPageSections,
|
|
96
184
|
}: Props): Promise<ReactElement> {
|
|
97
185
|
const { locale } = await params;
|
|
98
|
-
const [status, t] = await Promise.all([
|
|
186
|
+
const [status, t, magnet] = await Promise.all([
|
|
99
187
|
readCaptureStatus(),
|
|
100
188
|
getTranslations({ locale, namespace: "contact" }),
|
|
189
|
+
readContactMagnet(),
|
|
101
190
|
]);
|
|
102
191
|
const accepting = status?.contact ?? true;
|
|
103
192
|
const contactEmail = readSiteContactEmail();
|
|
@@ -115,17 +204,32 @@ export default async function Page({
|
|
|
115
204
|
formSection?.id ??
|
|
116
205
|
(lead?.type === "hero" && lead.data.variant === "split" ? lead.id : undefined);
|
|
117
206
|
|
|
207
|
+
// #8661 D6 — the magnet's fields, else the section's, else the built-in form.
|
|
208
|
+
const served = magnet ? contactFormFromConfig(magnet, locale) : null;
|
|
209
|
+
const declared = formSection && formSection.data.fields.length > 0 ? formSection : undefined;
|
|
210
|
+
const fields: ContactFormField[] =
|
|
211
|
+
served?.fields ?? (declared ? sectionFields(declared) : builtInFields(text, locale));
|
|
212
|
+
// A magnet's own submit label wins; the section's copy fills what it leaves.
|
|
213
|
+
const submitLabel = served?.submitLabel ?? formSection?.data.submitLabel;
|
|
214
|
+
const successMessage = formSection?.data.successMessage;
|
|
215
|
+
const column = formSection?.data.variant === "column";
|
|
216
|
+
|
|
118
217
|
const surface = accepting ? (
|
|
119
218
|
<LeadCaptureForm
|
|
120
219
|
action={captureLeadAction}
|
|
121
|
-
text={text}
|
|
220
|
+
text={served?.consentStatement ? { ...text, consentStatement: served.consentStatement } : text}
|
|
122
221
|
termsHref={localeHref(locale, "/terms")}
|
|
222
|
+
policies={{
|
|
223
|
+
terms: localeHref(locale, "/terms"),
|
|
224
|
+
privacy: localeHref(locale, "/privacy"),
|
|
225
|
+
working_agreement: localeHref(locale, "/working-agreement"),
|
|
226
|
+
}}
|
|
123
227
|
fallbackEmail={fallbackEmail}
|
|
124
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
})}
|
|
228
|
+
fields={fields}
|
|
229
|
+
submitLabel={submitLabel}
|
|
230
|
+
successMessage={successMessage}
|
|
231
|
+
{...(served && { source: flowLeadSource(CONTACT_FORM_FLOW_SLUG) })}
|
|
232
|
+
{...(column && { look: "column" as const, consentGate: "on-submit" as const })}
|
|
129
233
|
/>
|
|
130
234
|
) : (
|
|
131
235
|
<ContactFallback
|