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,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standard checkout return pages (#8748 D1, D5, D11; spec
|
|
3
|
+
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2).
|
|
4
|
+
*
|
|
5
|
+
* Both pages are noindex, render their namespace's copy, read no search
|
|
6
|
+
* parameter and never import the checkout standard. On any build that is
|
|
7
|
+
* not a spoke they call `notFound()` before reading a translation.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { render } from "@testing-library/react";
|
|
13
|
+
|
|
14
|
+
const mockNotFound = jest.fn(() => {
|
|
15
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
16
|
+
});
|
|
17
|
+
jest.mock("next/navigation", () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
notFound: () => mockNotFound(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const mockGetTranslations = jest.fn();
|
|
23
|
+
jest.mock("next-intl/server", () => {
|
|
24
|
+
const actual = jest.requireActual("next-intl/server");
|
|
25
|
+
return {
|
|
26
|
+
...actual,
|
|
27
|
+
getTranslations: (arg: unknown) => {
|
|
28
|
+
mockGetTranslations(arg);
|
|
29
|
+
return actual.getTranslations(arg);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
35
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
36
|
+
jest.mock("../../../../lib/topology.generated", () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
get TOPOLOGY() {
|
|
39
|
+
return mockTopology.value;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// The thank-you page reads a paid session through the package (#8757 D13);
|
|
44
|
+
// with no `session_id` it never calls it, so a stub is enough here.
|
|
45
|
+
// `virtual`: CI runs this suite before the package's dist is built.
|
|
46
|
+
jest.mock(
|
|
47
|
+
"@working-theory/spoke-checkout",
|
|
48
|
+
() => ({ __esModule: true, readPaidCheckoutEmail: jest.fn(async () => null) }),
|
|
49
|
+
{ virtual: true },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
import messages from "../../../../messages/en.json";
|
|
53
|
+
import ThankYouPage, {
|
|
54
|
+
generateMetadata as thankYouMetadata,
|
|
55
|
+
} from "../thank-you/page";
|
|
56
|
+
import UnavailablePage, {
|
|
57
|
+
generateMetadata as unavailableMetadata,
|
|
58
|
+
} from "../unavailable/page";
|
|
59
|
+
|
|
60
|
+
type PageModule = {
|
|
61
|
+
name: string;
|
|
62
|
+
dir: string;
|
|
63
|
+
namespace: "checkoutThankYou" | "checkoutUnavailable";
|
|
64
|
+
Page: typeof ThankYouPage;
|
|
65
|
+
generateMetadata: typeof thankYouMetadata;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const PAGES: PageModule[] = [
|
|
69
|
+
{
|
|
70
|
+
name: "thank-you",
|
|
71
|
+
dir: "thank-you",
|
|
72
|
+
namespace: "checkoutThankYou",
|
|
73
|
+
Page: ThankYouPage,
|
|
74
|
+
generateMetadata: thankYouMetadata,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "unavailable",
|
|
78
|
+
dir: "unavailable",
|
|
79
|
+
namespace: "checkoutUnavailable",
|
|
80
|
+
Page: UnavailablePage,
|
|
81
|
+
generateMetadata: unavailableMetadata,
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const params = (): Promise<{ locale: string }> =>
|
|
86
|
+
Promise.resolve({ locale: "en" });
|
|
87
|
+
|
|
88
|
+
function source(dir: string): string {
|
|
89
|
+
return readFileSync(join(__dirname, "..", dir, "page.tsx"), "utf8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
mockNotFound.mockClear();
|
|
94
|
+
mockGetTranslations.mockClear();
|
|
95
|
+
mockTopology.value = "spoke";
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe.each(PAGES)("the checkout $name page (#8748 AC-2)", (page) => {
|
|
99
|
+
it("is noindex", async () => {
|
|
100
|
+
const metadata = await page.generateMetadata({ params: params() });
|
|
101
|
+
expect(metadata.robots).toEqual({ index: false, follow: false });
|
|
102
|
+
expect(metadata.title).toBe(messages[page.namespace].title);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("renders its namespace's title", async () => {
|
|
106
|
+
const { getByText } = render(await page.Page({ params: params() }));
|
|
107
|
+
expect(getByText(messages[page.namespace].title)).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("never imports lib/checkout, and only the thank-you page reads searchParams (#8757 D13)", () => {
|
|
111
|
+
const text = source(page.dir);
|
|
112
|
+
expect(text).not.toMatch(/lib\/checkout["']/);
|
|
113
|
+
if (page.dir !== "thank-you") expect(text).not.toContain("searchParams");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("calls notFound() before any translation read when the build is a hub (D11)", async () => {
|
|
117
|
+
mockTopology.value = "hub";
|
|
118
|
+
await expect(page.Page({ params: params() })).rejects.toThrow(
|
|
119
|
+
"NEXT_NOT_FOUND",
|
|
120
|
+
);
|
|
121
|
+
await expect(page.generateMetadata({ params: params() })).rejects.toThrow(
|
|
122
|
+
"NEXT_NOT_FOUND",
|
|
123
|
+
);
|
|
124
|
+
expect(mockNotFound).toHaveBeenCalledTimes(2);
|
|
125
|
+
expect(mockGetTranslations).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("the checkout unavailable page's way out", () => {
|
|
130
|
+
it("links /contact", async () => {
|
|
131
|
+
const { container } = render(await UnavailablePage({ params: params() }));
|
|
132
|
+
const hrefs = [...container.querySelectorAll("a")].map((a) =>
|
|
133
|
+
a.getAttribute("href"),
|
|
134
|
+
);
|
|
135
|
+
expect(hrefs.some((href) => href === "/contact" || href === "/en/contact")).toBe(
|
|
136
|
+
true,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkout thank-you page's "Open your account" link (#8757 D13, AC-9).
|
|
3
|
+
*
|
|
4
|
+
* A `session_id` of a paid session of this site renders the link with the
|
|
5
|
+
* buyer's email (`accountInviteHref`); anything the package answers `null`
|
|
6
|
+
* for renders it with no email. The page is noindex, sends no referrer,
|
|
7
|
+
* renders dynamically, never touches a cookie, and over a visitor's budget
|
|
8
|
+
* of 10 reads per 10 minutes it makes no Stripe read at all.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { render } from "@testing-library/react";
|
|
14
|
+
import { localeHref } from "@working-theory/ui";
|
|
15
|
+
|
|
16
|
+
const mockRead = jest.fn<Promise<string | null>, [{ site: string }, string]>();
|
|
17
|
+
// `virtual`: CI runs this suite before the package's dist is built.
|
|
18
|
+
jest.mock(
|
|
19
|
+
"@working-theory/spoke-checkout",
|
|
20
|
+
() => ({
|
|
21
|
+
__esModule: true,
|
|
22
|
+
readPaidCheckoutEmail: (config: { site: string }, id: string) =>
|
|
23
|
+
mockRead(config, id),
|
|
24
|
+
}),
|
|
25
|
+
{ virtual: true },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
jest.mock("next/navigation", () => ({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
notFound: () => {
|
|
31
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const mockVisitor = { ip: "203.0.113.7" };
|
|
36
|
+
jest.mock("next/headers", () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
headers: jest.fn(async () => ({
|
|
39
|
+
get: (name: string) => (name === "x-forwarded-for" ? mockVisitor.ip : null),
|
|
40
|
+
})),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER and TOPOLOGY are generated per-build constants (#8757 D13), not runtime inputs; no env or HTTP surface selects them.
|
|
44
|
+
jest.mock("../../../../lib/topology.generated", () => ({
|
|
45
|
+
__esModule: true,
|
|
46
|
+
SIGN_IN_PROVIDER: "site",
|
|
47
|
+
TOPOLOGY: "spoke",
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import { accountInviteHref } from "../../../../lib/account-invite";
|
|
51
|
+
import { checkoutConfig } from "../../../../lib/checkout-config";
|
|
52
|
+
import messages from "../../../../messages/en.json";
|
|
53
|
+
import { __resetRateLimit } from "../../../../src/lib/rate-limit";
|
|
54
|
+
import * as pageModule from "../thank-you/page";
|
|
55
|
+
|
|
56
|
+
const SESSION = "cs_test_paidOwnSite01";
|
|
57
|
+
const EMAIL = "buyer@example.com";
|
|
58
|
+
|
|
59
|
+
const params = (): Promise<{ locale: string }> =>
|
|
60
|
+
Promise.resolve({ locale: "en" });
|
|
61
|
+
const search = (q: Record<string, string>) => Promise.resolve(q);
|
|
62
|
+
|
|
63
|
+
async function linkHref(
|
|
64
|
+
q: Record<string, string> = { session_id: SESSION },
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const { getByTestId, unmount } = render(
|
|
67
|
+
await pageModule.default({ params: params(), searchParams: search(q) }),
|
|
68
|
+
);
|
|
69
|
+
const link = getByTestId("checkout-open-account");
|
|
70
|
+
expect(link.textContent).toBe(messages.checkoutThankYou.openAccount);
|
|
71
|
+
const href = link.getAttribute("href") ?? "";
|
|
72
|
+
unmount();
|
|
73
|
+
return href;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
mockRead.mockReset();
|
|
78
|
+
__resetRateLimit();
|
|
79
|
+
mockVisitor.ip = "203.0.113.7";
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("the thank-you page's account link (#8757 D13, AC-9)", () => {
|
|
83
|
+
it("links accountInviteHref(locale, email) for a paid own-site session", async () => {
|
|
84
|
+
mockRead.mockResolvedValue(EMAIL);
|
|
85
|
+
expect(await linkHref()).toBe(accountInviteHref("en", EMAIL));
|
|
86
|
+
expect(mockRead).toHaveBeenCalledWith(
|
|
87
|
+
{ site: checkoutConfig.site },
|
|
88
|
+
SESSION,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("links with no email parameter when the read answers null", async () => {
|
|
93
|
+
mockRead.mockResolvedValue(null);
|
|
94
|
+
const href = await linkHref();
|
|
95
|
+
expect(href).not.toContain("email=");
|
|
96
|
+
expect(href).toBe(localeHref("en", "/account"));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("links with no email and makes no read when there is no session_id", async () => {
|
|
100
|
+
expect(await linkHref({})).not.toContain("email=");
|
|
101
|
+
expect(mockRead).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("makes no read on the 11th visit from one visitor within 10 minutes", async () => {
|
|
105
|
+
mockRead.mockResolvedValue(EMAIL);
|
|
106
|
+
for (let i = 0; i < 10; i += 1) await linkHref();
|
|
107
|
+
expect(mockRead).toHaveBeenCalledTimes(10);
|
|
108
|
+
const href = await linkHref();
|
|
109
|
+
expect(mockRead).toHaveBeenCalledTimes(10);
|
|
110
|
+
expect(href).not.toContain("email=");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("counts each visitor separately", async () => {
|
|
114
|
+
mockRead.mockResolvedValue(EMAIL);
|
|
115
|
+
for (let i = 0; i < 10; i += 1) await linkHref();
|
|
116
|
+
mockVisitor.ip = "198.51.100.9";
|
|
117
|
+
expect(await linkHref()).toBe(accountInviteHref("en", EMAIL));
|
|
118
|
+
expect(mockRead).toHaveBeenCalledTimes(11);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("is noindex, sends no referrer and renders dynamically", async () => {
|
|
122
|
+
const metadata = await pageModule.generateMetadata({ params: params() });
|
|
123
|
+
expect(metadata.referrer).toBe("no-referrer");
|
|
124
|
+
expect(metadata.robots).toEqual({ index: false, follow: false });
|
|
125
|
+
expect(pageModule.dynamic).toBe("force-dynamic");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("never reads or sets a cookie", () => {
|
|
129
|
+
const text = readFileSync(
|
|
130
|
+
join(__dirname, "..", "thank-you", "page.tsx"),
|
|
131
|
+
"utf8",
|
|
132
|
+
);
|
|
133
|
+
expect(text).not.toMatch(/\bcookies\b/);
|
|
134
|
+
expect(text).not.toMatch(/\.set\(/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The checkout thank-you page — Standard spoke checkout page (#8748).
|
|
2
|
+
// Kept current by `create-ailk update`: do not edit it. This site's checkout
|
|
3
|
+
// data is lib/checkout-config.ts, the one file to edit.
|
|
4
|
+
// Stripe returns a buyer here after paying. It calls neither the hub nor any
|
|
5
|
+
// fulfilment: the webhook is what fulfils the order. It reads the one
|
|
6
|
+
// `session_id` Stripe appended, to offer "Open your account" with the buyer's
|
|
7
|
+
// email (#8757 D13): a paid session of this site, under 24 hours old, read
|
|
8
|
+
// within a per-visitor budget, held per server instance (best effort: the
|
|
9
|
+
// session id's length, not this budget, is what stops guessing). It never
|
|
10
|
+
// sets a cookie and never signs anyone in. It is served on a spoke only: any other build answers notFound().
|
|
11
|
+
import { readPaidCheckoutEmail } from "@working-theory/spoke-checkout";
|
|
12
|
+
import { Button, Container, Link, localeHref } from "@working-theory/ui";
|
|
13
|
+
import { SectionRenderer } from "@working-theory/ui/sections";
|
|
14
|
+
import type { PageSection } from "@working-theory/validation";
|
|
15
|
+
import type { Metadata } from "next";
|
|
16
|
+
import { headers } from "next/headers";
|
|
17
|
+
import { notFound } from "next/navigation";
|
|
18
|
+
import { getTranslations } from "next-intl/server";
|
|
19
|
+
import type { ReactElement } from "react";
|
|
20
|
+
|
|
21
|
+
import { accountInviteHref } from "../../../../lib/account-invite";
|
|
22
|
+
import { checkoutConfig } from "../../../../lib/checkout-config";
|
|
23
|
+
import { TOPOLOGY } from "../../../../lib/topology.generated";
|
|
24
|
+
import {
|
|
25
|
+
checkRateLimit,
|
|
26
|
+
clientIpFromHeaders,
|
|
27
|
+
} from "../../../../src/lib/rate-limit";
|
|
28
|
+
|
|
29
|
+
// Next sends `private, no-cache, no-store` for a force-dynamic render, so the
|
|
30
|
+
// page (it can carry an email) is never cached (#8757 D13).
|
|
31
|
+
export const dynamic = "force-dynamic";
|
|
32
|
+
|
|
33
|
+
/** The Stripe reads one visitor may spend: 10 per 10 minutes (#8757 D13). */
|
|
34
|
+
const PAID_EMAIL_READS = 10;
|
|
35
|
+
const PAID_EMAIL_WINDOW_MS = 10 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
interface Props {
|
|
38
|
+
params: Promise<{ locale: string }>;
|
|
39
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
43
|
+
if (TOPOLOGY !== "spoke") notFound();
|
|
44
|
+
const { locale } = await params;
|
|
45
|
+
const t = await getTranslations({ locale, namespace: "checkoutThankYou" });
|
|
46
|
+
return {
|
|
47
|
+
title: t("title"),
|
|
48
|
+
robots: { index: false, follow: false },
|
|
49
|
+
referrer: "no-referrer",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The buyer's email, or `null` — over the visitor's budget, no Stripe call. */
|
|
54
|
+
async function paidEmail(
|
|
55
|
+
sessionId: string | string[] | undefined,
|
|
56
|
+
): Promise<string | null> {
|
|
57
|
+
// A repeated `session_id` is ignored, like a missing one.
|
|
58
|
+
if (typeof sessionId !== "string" || sessionId === "") return null;
|
|
59
|
+
const visitor = clientIpFromHeaders(await headers());
|
|
60
|
+
if (
|
|
61
|
+
!checkRateLimit(
|
|
62
|
+
`checkout-paid-email:${visitor}`,
|
|
63
|
+
PAID_EMAIL_READS,
|
|
64
|
+
PAID_EMAIL_WINDOW_MS,
|
|
65
|
+
)
|
|
66
|
+
) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return readPaidCheckoutEmail({ site: checkoutConfig.site }, sessionId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default async function Page({
|
|
73
|
+
params,
|
|
74
|
+
searchParams,
|
|
75
|
+
}: Props): Promise<ReactElement> {
|
|
76
|
+
if (TOPOLOGY !== "spoke") notFound();
|
|
77
|
+
const { locale } = await params;
|
|
78
|
+
const t = await getTranslations({ locale, namespace: "checkoutThankYou" });
|
|
79
|
+
const sessionId = (await searchParams)?.session_id;
|
|
80
|
+
const email = await paidEmail(sessionId);
|
|
81
|
+
const href =
|
|
82
|
+
(email === null ? null : accountInviteHref(locale, email)) ??
|
|
83
|
+
localeHref(locale, "/account");
|
|
84
|
+
const sections: PageSection[] = [
|
|
85
|
+
{
|
|
86
|
+
id: "checkout-thank-you",
|
|
87
|
+
type: "content-block",
|
|
88
|
+
data: { headline: t("title"), body: t("body") },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
return (
|
|
92
|
+
<main id="main">
|
|
93
|
+
<SectionRenderer
|
|
94
|
+
sections={sections}
|
|
95
|
+
locale={locale}
|
|
96
|
+
leadingSectionOwnsTitle
|
|
97
|
+
/>
|
|
98
|
+
{/* Session Replay records the DOM, so the link (it can carry the email) is blocked from it. */}
|
|
99
|
+
<Container className="pb-section">
|
|
100
|
+
<div data-sentry-block>
|
|
101
|
+
<Button asChild variant="outline" className="min-h-11">
|
|
102
|
+
<Link
|
|
103
|
+
href={href}
|
|
104
|
+
variant="bare"
|
|
105
|
+
data-testid="checkout-open-account"
|
|
106
|
+
>
|
|
107
|
+
{t("openAccount")}
|
|
108
|
+
</Link>
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</Container>
|
|
112
|
+
</main>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The checkout unavailable page — Standard spoke checkout page (#8748).
|
|
2
|
+
// Kept current by `create-ailk update`: do not edit it. This site's checkout
|
|
3
|
+
// data is lib/checkout-config.ts, the one file to edit.
|
|
4
|
+
// Every refused buy lands here. It never says why: a reason is a probe's
|
|
5
|
+
// answer, and the buyer needs only that nothing was charged.
|
|
6
|
+
// It is served on a spoke only: any other build answers notFound().
|
|
7
|
+
import { SectionRenderer } from "@working-theory/ui/sections";
|
|
8
|
+
import type { PageSection } from "@working-theory/validation";
|
|
9
|
+
import type { Metadata } from "next";
|
|
10
|
+
import { notFound } from "next/navigation";
|
|
11
|
+
import { getTranslations } from "next-intl/server";
|
|
12
|
+
import type { ReactElement } from "react";
|
|
13
|
+
|
|
14
|
+
import { TOPOLOGY } from "../../../../lib/topology.generated";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
params: Promise<{ locale: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
21
|
+
if (TOPOLOGY !== "spoke") notFound();
|
|
22
|
+
const { locale } = await params;
|
|
23
|
+
const t = await getTranslations({ locale, namespace: "checkoutUnavailable" });
|
|
24
|
+
return { title: t("title"), robots: { index: false, follow: false } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default async function Page({ params }: Props): Promise<ReactElement> {
|
|
28
|
+
if (TOPOLOGY !== "spoke") notFound();
|
|
29
|
+
const { locale } = await params;
|
|
30
|
+
const t = await getTranslations({ locale, namespace: "checkoutUnavailable" });
|
|
31
|
+
const sections: PageSection[] = [
|
|
32
|
+
{
|
|
33
|
+
id: "checkout-unavailable",
|
|
34
|
+
type: "content-block",
|
|
35
|
+
data: { headline: t("title"), body: t("body") },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "checkout-unavailable-cta",
|
|
39
|
+
type: "cta",
|
|
40
|
+
data: {
|
|
41
|
+
headline: t("ctaHeadline"),
|
|
42
|
+
primaryCta: { label: t("ctaLabel"), href: "/contact" },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
return (
|
|
47
|
+
<main id="main">
|
|
48
|
+
<SectionRenderer sections={sections} locale={locale} leadingSectionOwnsTitle />
|
|
49
|
+
</main>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -79,7 +79,7 @@ describe("ClientsPage — snapshot regression", () => {
|
|
|
79
79
|
});
|
|
80
80
|
const { container } = render(element);
|
|
81
81
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
82
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><h1>Our Clients</h1><p data-testid="clients-description" class="mt-block-tight text-lead">Companies that trust us.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-
|
|
82
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Our Clients","description":"Companies that trust us.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Organization"}]}}</script><main id="main"><div><h1>Our Clients</h1><p data-testid="clients-description" class="mt-block-tight text-lead">Companies that trust us.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
|
|
83
83
|
);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
@@ -81,7 +81,11 @@ function fireIntakeTurn(input: Parameters<typeof recordIntakeTurn>[0]): void {
|
|
|
81
81
|
.catch(() => {});
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Builds the FormData captureLeadAction expects from the engine's answers.
|
|
86
|
+
* Called only once the terms turn was accepted, so it posts consent=true
|
|
87
|
+
* (#8730 D2) — the action checks it for every source.
|
|
88
|
+
*/
|
|
85
89
|
function answersToFormData(answers: Record<string, string>): FormData {
|
|
86
90
|
const formData = new FormData();
|
|
87
91
|
if (answers["name"] !== undefined) formData.set("name", answers["name"]);
|
|
@@ -89,6 +93,7 @@ function answersToFormData(answers: Record<string, string>): FormData {
|
|
|
89
93
|
if (answers["message"] !== undefined)
|
|
90
94
|
formData.set("message", answers["message"]);
|
|
91
95
|
formData.set("source", "conversational-intake");
|
|
96
|
+
formData.set("consent", "true");
|
|
92
97
|
return formData;
|
|
93
98
|
}
|
|
94
99
|
|
|
@@ -116,6 +121,10 @@ export function ConversationalIntakeWrapper({
|
|
|
116
121
|
messagePrompt: t("intake.messagePrompt"),
|
|
117
122
|
emailAck: t("intake.emailAck"),
|
|
118
123
|
emailPrompt: t("intake.emailPrompt"),
|
|
124
|
+
consentAck: t("intake.consentAck"),
|
|
125
|
+
consentPrompt: t("intake.consentPrompt"),
|
|
126
|
+
consentAccept: t("intake.consentAccept"),
|
|
127
|
+
consentDecline: t("intake.consentDecline"),
|
|
119
128
|
skipMessage: t("intake.skipMessage"),
|
|
120
129
|
completionMessage: t("intake.completionMessage"),
|
|
121
130
|
}),
|
|
@@ -162,6 +171,13 @@ export function ConversationalIntakeWrapper({
|
|
|
162
171
|
status: "completed",
|
|
163
172
|
locale,
|
|
164
173
|
});
|
|
174
|
+
// #8730 D2 — the engine records the tapped pill's label; anything but
|
|
175
|
+
// the accept label is not acceptance, so nothing is sent and the form,
|
|
176
|
+
// with its own terms box, takes over.
|
|
177
|
+
if (answers["consent"] !== t("intake.consentAccept")) {
|
|
178
|
+
fallBackToForm(answers);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
165
181
|
void (async () => {
|
|
166
182
|
const result = await captureLeadAction(null, answersToFormData(answers));
|
|
167
183
|
if (result?.kind !== "success") {
|
|
@@ -200,6 +216,13 @@ export function ConversationalIntakeWrapper({
|
|
|
200
216
|
action={captureLeadAction}
|
|
201
217
|
text={contactFormText(t)}
|
|
202
218
|
termsHref={localeHref(locale, "/terms")}
|
|
219
|
+
// #8661 D7 — the same three policy links /contact passes, so the
|
|
220
|
+
// catalog's three-link statement resolves here too.
|
|
221
|
+
policies={{
|
|
222
|
+
terms: localeHref(locale, "/terms"),
|
|
223
|
+
privacy: localeHref(locale, "/privacy"),
|
|
224
|
+
working_agreement: localeHref(locale, "/working-agreement"),
|
|
225
|
+
}}
|
|
203
226
|
initialValues={initialValues}
|
|
204
227
|
/>
|
|
205
228
|
</Container>
|
package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx
CHANGED
|
@@ -94,6 +94,11 @@ async function flushMicrotasks(): Promise<void> {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** #8730 D2 — the intake's own terms turn: tap the accept pill. */
|
|
98
|
+
function acceptTerms(): void {
|
|
99
|
+
fireEvent.click(screen.getByTestId(`${TEST_ID}-pill-accept`));
|
|
100
|
+
}
|
|
101
|
+
|
|
97
102
|
function answer(text: string): void {
|
|
98
103
|
const input = screen.getByTestId(`${TEST_ID}-free-text-input`);
|
|
99
104
|
fireEvent.change(input, { target: { value: text } });
|
|
@@ -117,16 +122,18 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
|
|
|
117
122
|
advanceThinking(); // turn 3 (email) revealed
|
|
118
123
|
|
|
119
124
|
answer("jane@example.com");
|
|
125
|
+
advanceThinking(); // turn 4 (terms) revealed
|
|
126
|
+
acceptTerms();
|
|
120
127
|
advanceThinking(); // completion revealed, onComplete fires
|
|
121
128
|
|
|
122
129
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
123
130
|
const body = mockSubmit.mock.calls[0][0] as {
|
|
124
|
-
siteId: string;
|
|
125
131
|
source: string;
|
|
126
132
|
email: string;
|
|
127
133
|
payload: { name: string; message?: string };
|
|
128
134
|
};
|
|
129
|
-
|
|
135
|
+
// #8747 — no siteId: the API names the workspace itself.
|
|
136
|
+
expect(body).not.toHaveProperty("siteId");
|
|
130
137
|
expect(body.source).toBe("conversational-intake");
|
|
131
138
|
expect(body.email).toBe("jane@example.com");
|
|
132
139
|
expect(body.payload.name).toBe("Jane Smith");
|
|
@@ -147,6 +154,8 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
|
|
|
147
154
|
answer("I'd like a demo.");
|
|
148
155
|
advanceThinking();
|
|
149
156
|
answer("jane@example.com");
|
|
157
|
+
advanceThinking(); // turn 4 (terms) revealed
|
|
158
|
+
acceptTerms();
|
|
150
159
|
advanceThinking();
|
|
151
160
|
await flushMicrotasks();
|
|
152
161
|
|
|
@@ -165,6 +174,68 @@ describe("ConversationalIntakeWrapper — completion (AC1)", () => {
|
|
|
165
174
|
});
|
|
166
175
|
});
|
|
167
176
|
|
|
177
|
+
describe("ConversationalIntakeWrapper — the intake's own terms turn (#8730 D2)", () => {
|
|
178
|
+
/** Answers the three open turns and reveals the terms turn. */
|
|
179
|
+
function reachTerms(): void {
|
|
180
|
+
render(<ConversationalIntakeWrapper />);
|
|
181
|
+
advanceThinking();
|
|
182
|
+
answer("Jane Smith");
|
|
183
|
+
advanceThinking();
|
|
184
|
+
answer("I'd like a demo.");
|
|
185
|
+
advanceThinking();
|
|
186
|
+
answer("jane@example.com");
|
|
187
|
+
advanceThinking(); // turn 4 (terms) revealed
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("asks for the terms before it submits, offering accept and decline", () => {
|
|
191
|
+
reachTerms();
|
|
192
|
+
|
|
193
|
+
expect(screen.getByText("intake.consentPrompt")).toBeInTheDocument();
|
|
194
|
+
expect(screen.getByTestId(`${TEST_ID}-pill-accept`)).toHaveTextContent("intake.consentAccept");
|
|
195
|
+
expect(screen.getByTestId(`${TEST_ID}-pill-decline`)).toHaveTextContent("intake.consentDecline");
|
|
196
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("accepting submits the lead carrying consent: true", async () => {
|
|
200
|
+
mockSubmit.mockResolvedValue({ ok: true, body: { id: "lead_abc123", persisted: "db" } });
|
|
201
|
+
|
|
202
|
+
reachTerms();
|
|
203
|
+
acceptTerms();
|
|
204
|
+
advanceThinking();
|
|
205
|
+
await flushMicrotasks();
|
|
206
|
+
|
|
207
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
208
|
+
const body = mockSubmit.mock.calls[0][0] as { source: string; consent: unknown };
|
|
209
|
+
expect(body.source).toBe("conversational-intake");
|
|
210
|
+
expect(body.consent).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("declining submits nothing and shows the form, prefilled, with its own terms box", async () => {
|
|
214
|
+
reachTerms();
|
|
215
|
+
fireEvent.click(screen.getByTestId(`${TEST_ID}-pill-decline`));
|
|
216
|
+
advanceThinking();
|
|
217
|
+
await flushMicrotasks();
|
|
218
|
+
|
|
219
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
220
|
+
const stub = screen.getByTestId("contact-form-stub");
|
|
221
|
+
expect(JSON.parse(stub.getAttribute("data-initial-values") ?? "{}")).toMatchObject({
|
|
222
|
+
name: "Jane Smith",
|
|
223
|
+
email: "jane@example.com",
|
|
224
|
+
message: "I'd like a demo.",
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("typing anything but the accept answer is not acceptance", async () => {
|
|
229
|
+
reachTerms();
|
|
230
|
+
answer("sure, whatever");
|
|
231
|
+
advanceThinking();
|
|
232
|
+
await flushMicrotasks();
|
|
233
|
+
|
|
234
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
235
|
+
expect(screen.getByTestId("contact-form-stub")).toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
168
239
|
describe("ConversationalIntakeWrapper — Skip (AC2)", () => {
|
|
169
240
|
it("renders the existing form on Skip, carrying over the answers captured so far", () => {
|
|
170
241
|
render(<ConversationalIntakeWrapper />);
|
|
@@ -199,6 +270,8 @@ describe("ConversationalIntakeWrapper — per-turn abandonment analytics (AC3)",
|
|
|
199
270
|
answer("I'd like a demo.");
|
|
200
271
|
advanceThinking();
|
|
201
272
|
answer("jane@example.com");
|
|
273
|
+
advanceThinking(); // turn 4 (terms) revealed
|
|
274
|
+
acceptTerms();
|
|
202
275
|
advanceThinking();
|
|
203
276
|
await flushMicrotasks(); // recordIntakeTurn is dispatched on a later microtask (#6465)
|
|
204
277
|
|
|
@@ -211,10 +284,10 @@ describe("ConversationalIntakeWrapper — per-turn abandonment analytics (AC3)",
|
|
|
211
284
|
true,
|
|
212
285
|
);
|
|
213
286
|
const inProgress = calls.filter((c) => c.status === "in_progress");
|
|
214
|
-
expect(inProgress.map((c) => c.turnIndex)).toEqual([1, 2, 3, 4]);
|
|
287
|
+
expect(inProgress.map((c) => c.turnIndex)).toEqual([1, 2, 3, 4, 5]);
|
|
215
288
|
|
|
216
289
|
const terminal = calls[calls.length - 1];
|
|
217
|
-
expect(terminal).toMatchObject({ status: "completed", turnIndex:
|
|
290
|
+
expect(terminal).toMatchObject({ status: "completed", turnIndex: 5 });
|
|
218
291
|
|
|
219
292
|
// PII-free: only turnIndex/status/locale ever leave the client.
|
|
220
293
|
for (const call of mockTrack.mock.calls) {
|
|
@@ -317,6 +390,8 @@ describe("ConversationalIntakeWrapper — a failing analytics call never strands
|
|
|
317
390
|
answer("I'd like a demo.");
|
|
318
391
|
advanceThinking();
|
|
319
392
|
answer("jane@example.com");
|
|
393
|
+
advanceThinking(); // turn 4 (terms) revealed
|
|
394
|
+
acceptTerms();
|
|
320
395
|
advanceThinking(); // completion revealed, onComplete fires
|
|
321
396
|
await flushMicrotasks();
|
|
322
397
|
|