create-ailk 0.21.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.
Files changed (175) hide show
  1. package/component-catalog.json +37 -3
  2. package/component-catalog.md +6 -5
  3. package/dist/add-module.js +9 -5
  4. package/dist/derive-architecture.d.ts +8 -1
  5. package/dist/derive-architecture.js +38 -3
  6. package/dist/design-package.js +7 -2
  7. package/dist/lib/env-catalog.js +5 -1
  8. package/dist/module-architecture.js +4 -2
  9. package/dist/programmatic.js +19 -5
  10. package/dist/site-chrome.d.ts +8 -0
  11. package/dist/site-chrome.js +17 -1
  12. package/dist/spoke-checkout.d.ts +39 -18
  13. package/dist/spoke-checkout.js +177 -236
  14. package/dist/surfaces.js +19 -14
  15. package/dist/sync-routes.js +3 -2
  16. package/dist/template-record.d.ts +4 -3
  17. package/dist/template-record.js +6 -3
  18. package/dist/topology.js +25 -1
  19. package/dist/update.js +5 -0
  20. package/package.json +3 -3
  21. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  22. package/templates/apps/api/CLAUDE.md +15 -6
  23. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  24. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  25. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  26. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  27. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  28. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  29. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  30. package/templates/apps/api/src/middleware/auth.ts +13 -6
  31. package/templates/apps/api/src/openapi/spec.ts +30 -3
  32. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  33. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  34. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  35. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  36. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  37. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  38. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  39. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  40. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  41. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  42. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  43. package/templates/apps/api/src/server.ts +28 -0
  44. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  45. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  46. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  47. package/templates/apps/api/src/services/consent.ts +95 -0
  48. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  49. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  50. package/templates/apps/api/src/services/person.ts +40 -8
  51. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  52. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  53. package/templates/apps/web/.env.example +14 -0
  54. package/templates/apps/web/CLAUDE.md +120 -9
  55. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  56. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  57. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  58. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  59. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  60. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  61. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  62. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  63. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  64. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  65. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  67. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  68. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  69. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  70. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  71. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  72. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  78. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  80. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  81. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  82. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  83. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  85. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  86. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  87. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  88. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  89. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  90. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  91. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  92. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  93. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  94. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  95. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  96. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  97. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  98. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  99. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  100. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  101. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  102. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  103. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
  104. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
  105. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  106. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
  107. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  108. package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  112. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  113. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  114. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  115. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  116. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  119. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  120. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  122. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  124. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  125. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  126. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  127. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  128. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  129. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  130. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  131. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  132. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  133. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  134. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  135. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  136. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  137. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  138. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  139. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  140. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  141. package/templates/apps/web/auth.ts +28 -2
  142. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  143. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  144. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  145. package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
  146. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
  147. package/templates/apps/web/lib/account-invite.ts +37 -0
  148. package/templates/apps/web/lib/checkout-config.ts +27 -0
  149. package/templates/apps/web/lib/checkout.ts +93 -0
  150. package/templates/apps/web/lib/consent-policies.ts +22 -0
  151. package/templates/apps/web/lib/deck-page.ts +6 -0
  152. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  153. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  154. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  155. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  156. package/templates/apps/web/lib/site-chrome.ts +2 -0
  157. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  158. package/templates/apps/web/messages/en.json +24 -7
  159. package/templates/apps/web/package.json +1 -0
  160. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  161. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  162. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  163. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  164. package/templates/database/CHANGELOG.md +38 -0
  165. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  166. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  167. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  168. package/templates/database/__tests__/seed.test.ts +34 -1
  169. package/templates/database/inbox/schema.prisma +17 -2
  170. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  171. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  172. package/templates/database/package.json +1 -1
  173. package/templates/database/seed.ts +34 -3
  174. package/versions.json +34 -34
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -35,6 +35,15 @@
35
35
  * a `thankYou` block, so without this widening the callback would never be
36
36
  * supplied on a recommending flow at all.
37
37
  *
38
+ * Account link (#8771 D5): when the walk completes on an `invite` magnet (the
39
+ * step response's `next`), the client asks `accountInviteLinkAction` for the
40
+ * sign-in link with the email the visitor gave already filled in, then shows
41
+ * "Track this in your account" on `FlowStepper`'s own finished panel — or,
42
+ * for a flow with a `thankYou` block, hands the link to `/thanks/<slug>`
43
+ * through `sessionStorage` (`ailk:account-invite`). The email never enters a
44
+ * URL the walk navigates to (NFR1), and each link sits in a Session-Replay
45
+ * `data-sentry-block` element, since the scrub cannot reach a DOM `href`.
46
+ *
38
47
  * Locale validation before interpolation (AILK #4707, security review on
39
48
  * samhenry PR #163): the `[locale]` route segment is a Next.js dynamic
40
49
  * segment — the framework matches ANY string there, and next-intl's
@@ -49,6 +58,7 @@
49
58
  * bundle, the same reasoning `middleware.ts` already documents for the
50
59
  * Edge runtime) before it ever reaches the template literal.
51
60
  */
61
+ import { Button, Link } from "@working-theory/ui";
52
62
  import {
53
63
  FlowStepper,
54
64
  type FlowStepperMessages,
@@ -60,6 +70,7 @@ import {
60
70
  deriveFlags,
61
71
  encodePermalink,
62
72
  flattenState,
73
+ flowStepNextSchema,
63
74
  hiddenAnswers,
64
75
  PERMALINK_VERSION,
65
76
  pruneHiddenState,
@@ -68,8 +79,9 @@ import {
68
79
  import { useRouter } from "next/navigation";
69
80
  import { useMessages, useTranslations } from "next-intl";
70
81
  import type { ReactElement } from "react";
71
- import { useRef } from "react";
82
+ import { useRef, useState } from "react";
72
83
 
84
+ import { accountInviteLinkAction } from "./account-invite.actions";
73
85
  import { submitFlowStepAction } from "./flow.actions";
74
86
 
75
87
  interface Props {
@@ -88,6 +100,28 @@ function resolveSafeLocale(candidate: string): string {
88
100
  return ENABLED_LOCALES.includes(candidate) ? candidate : DEFAULT_LOCALE;
89
101
  }
90
102
 
103
+ /** The `/thanks` hand-off key (#8771 D5) — read once by `AccountInviteLink`. */
104
+ const ACCOUNT_INVITE_STORAGE_KEY = "ailk:account-invite";
105
+
106
+ /**
107
+ * #8771 D5 — the email the engine will land as the lead's: the first
108
+ * `mapsTo: "email"` field of the submitted `field` step that holds a
109
+ * non-empty string. Mirrors `extractMappedEmail` (a later step wins).
110
+ */
111
+ function mappedEmailOf(
112
+ config: FlowConfig,
113
+ payload: FlowStepSubmitPayload,
114
+ ): string | undefined {
115
+ const step = config.steps.find((s) => s.key === payload.stepKey);
116
+ if (step?.type !== "field") return undefined;
117
+ for (const field of step.fields) {
118
+ if (field.mapsTo !== "email") continue;
119
+ const value = payload.values[field.name];
120
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
121
+ }
122
+ return undefined;
123
+ }
124
+
91
125
  /**
92
126
  * D7 (#4770) — every question `name` contributed by a `segmentation`-typed
93
127
  * step. This is the allowlist the encoded permalink is filtered to: a
@@ -130,6 +164,13 @@ export function FlowStepperClient({
130
164
  // recommending completion can hand the walk's session to the results page
131
165
  // via `sessionStorage` (never the URL — the permalink stays answers-only).
132
166
  const lastSessionIdRef = useRef<string | undefined>(undefined);
167
+ // #8771 D5 — the email the walk has landed, and the account link built from
168
+ // it at completion on an `invite` magnet. The ref carries the link to
169
+ // `handleCompleted`; the state draws it on the finished panel.
170
+ const emailRef = useRef<string | undefined>(undefined);
171
+ const inviteHrefRef = useRef<string | null>(null);
172
+ const [inviteHref, setInviteHref] = useState<string | null>(null);
173
+ const tContact = useTranslations("contact");
133
174
 
134
175
  async function handleSubmitStep(
135
176
  payload: FlowStepSubmitPayload,
@@ -141,12 +182,36 @@ export function FlowStepperClient({
141
182
  [payload.stepKey]: payload.values,
142
183
  };
143
184
  lastSessionIdRef.current = result.response.sessionId;
185
+ emailRef.current = mappedEmailOf(config, payload) ?? emailRef.current;
186
+ if (result.response.completed) {
187
+ await resolveInviteLink(result.response);
188
+ }
144
189
  } else if (result.kind === "step_conflict") {
145
190
  answersRef.current = {};
191
+ emailRef.current = undefined;
146
192
  }
147
193
  return result;
148
194
  }
149
195
 
196
+ // #8771 D5 — on an `invite` completion with a landed email, ask the server
197
+ // for the prefilled sign-in link. A rejected or null answer gives no link.
198
+ async function resolveInviteLink(response: unknown): Promise<void> {
199
+ inviteHrefRef.current = null;
200
+ setInviteHref(null);
201
+ const next = flowStepNextSchema.safeParse(
202
+ (response as { next?: unknown }).next,
203
+ );
204
+ const email = emailRef.current;
205
+ if (!next.success || email === undefined || isRecommending) return;
206
+ try {
207
+ const href = await accountInviteLinkAction(email);
208
+ inviteHrefRef.current = href;
209
+ setInviteHref(href);
210
+ } catch {
211
+ // No link — the walk still completes.
212
+ }
213
+ }
214
+
150
215
  // #4663 D11 — `aeoScore` passes through untouched: this repo's segment
151
216
  // redirect is unchanged; C2's results page (on samhenry) is the consumer
152
217
  // of the score.
@@ -209,6 +274,26 @@ export function FlowStepperClient({
209
274
 
210
275
  if (!thankYou) return;
211
276
  const thankYouSlug = resolveThankYouSlug(thankYou, result.segment);
277
+ // #8771 D5 — hand the link to /thanks through the per-tab sessionStorage,
278
+ // never the URL; with no link, clear an earlier walk's so it never shows
279
+ // on this page. A storage that throws gives no link.
280
+ if (typeof window !== "undefined") {
281
+ try {
282
+ if (inviteHrefRef.current !== null) {
283
+ window.sessionStorage.setItem(
284
+ ACCOUNT_INVITE_STORAGE_KEY,
285
+ JSON.stringify({
286
+ thanks: thankYouSlug,
287
+ href: inviteHrefRef.current,
288
+ }),
289
+ );
290
+ } else {
291
+ window.sessionStorage.removeItem(ACCOUNT_INVITE_STORAGE_KEY);
292
+ }
293
+ } catch {
294
+ // No hand-off.
295
+ }
296
+ }
212
297
  router.push(`/${resolveSafeLocale(locale)}/thanks/${thankYouSlug}`);
213
298
  }
214
299
 
@@ -252,6 +337,25 @@ export function FlowStepperClient({
252
337
  selectAllThatApply: t("selectAllThatApply"),
253
338
  };
254
339
 
340
+ // #8771 D5 — the link goes INSIDE FlowStepper's own finished card (a flow
341
+ // with a `thankYou` block navigates away instead, so it never shows there).
342
+ // Session Replay records the DOM, so the link (it carries the email) is
343
+ // blocked from it; `Link` does not forward an undeclared data-* attribute.
344
+ const completedAction =
345
+ inviteHref !== null && thankYou === undefined && !isRecommending ? (
346
+ <div data-sentry-block>
347
+ <Button asChild variant="outline" className="mt-4 min-h-11">
348
+ <Link
349
+ href={inviteHref}
350
+ variant="bare"
351
+ data-testid="flow-account-invite"
352
+ >
353
+ {tContact("trackInAccount")}
354
+ </Link>
355
+ </Button>
356
+ </div>
357
+ ) : undefined;
358
+
255
359
  return (
256
360
  <FlowStepper
257
361
  config={config}
@@ -261,6 +365,7 @@ export function FlowStepperClient({
261
365
  {...(thankYou !== undefined || isRecommending
262
366
  ? { onCompleted: handleCompleted }
263
367
  : {})}
368
+ {...(completedAction !== undefined ? { completedAction } : {})}
264
369
  />
265
370
  );
266
371
  }
@@ -0,0 +1,257 @@
1
+ // oss: false
2
+ /**
3
+ * #8771 AC-4 — the walk shows "Track this in your account" with the email the
4
+ * visitor gave already filled in, and keeps that email out of every URL it
5
+ * navigates to. FlowStepper is a stub that submits a configurable step and
6
+ * calls the real `onCompleted` it is given; the Server Actions are doubles.
7
+ */
8
+ import { render, screen, waitFor } from "@testing-library/react";
9
+ import userEvent from "@testing-library/user-event";
10
+ import type { FlowConfig } from "@working-theory/validation";
11
+ import type { ReactElement } from "react";
12
+
13
+ const mockPush = jest.fn();
14
+ jest.mock("next/navigation", () => ({
15
+ useRouter: () => ({ push: mockPush }),
16
+ }));
17
+
18
+ jest.mock("next-intl", () => ({
19
+ useTranslations: () => (key: string) => key,
20
+ useMessages: () => ({}),
21
+ }));
22
+
23
+ // eslint-disable-next-line no-restricted-syntax -- the boundary under test is the client wiring, not the actions
24
+ jest.mock("../flow.actions", () => ({ submitFlowStepAction: jest.fn() }));
25
+ // eslint-disable-next-line no-restricted-syntax -- see above
26
+ jest.mock("../account-invite.actions", () => ({ accountInviteLinkAction: jest.fn() }));
27
+
28
+ type Step = { stepKey: string; values: Record<string, unknown> };
29
+ let steps: Step[] = [];
30
+
31
+ interface StubProps {
32
+ onSubmitStep: (payload: Step & { locale?: string }) => Promise<{
33
+ ok: boolean;
34
+ response?: { completed: boolean; segment?: string };
35
+ }>;
36
+ onCompleted?: (result: { segment?: string }) => void;
37
+ completedAction?: ReactElement;
38
+ }
39
+
40
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
41
+ FlowStepper: (props: StubProps) => {
42
+ async function handleSubmit(): Promise<void> {
43
+ for (const step of steps) {
44
+ const result = await props.onSubmitStep({ ...step, locale: "en" });
45
+ if (result.ok && result.response?.completed) {
46
+ props.onCompleted?.(
47
+ result.response.segment !== undefined ? { segment: result.response.segment } : {},
48
+ );
49
+ }
50
+ }
51
+ }
52
+ // The real FlowStepper renders `completedAction` inside its finished card.
53
+ return require("react").createElement(
54
+ "div",
55
+ { "data-testid": "mock-completed-card" },
56
+ require("react").createElement(
57
+ "button",
58
+ { "data-testid": "mock-submit", onClick: handleSubmit },
59
+ "Submit",
60
+ ),
61
+ props.completedAction,
62
+ ) as ReactElement;
63
+ },
64
+ }));
65
+
66
+ import { accountInviteLinkAction } from "../account-invite.actions";
67
+ import { submitFlowStepAction } from "../flow.actions";
68
+ import { FlowStepperClient } from "../FlowStepperClient";
69
+
70
+ const mockSubmit = submitFlowStepAction as jest.MockedFunction<typeof submitFlowStepAction>;
71
+ const mockLink = accountInviteLinkAction as jest.MockedFunction<typeof accountInviteLinkAction>;
72
+
73
+ const HREF = "/sign-in?next=%2Faccount&email=jane%40example.com";
74
+ const EMAIL = "jane@example.com";
75
+
76
+ function config(opts: { tagged: boolean; thankYou: boolean; secondEmail?: boolean }): FlowConfig {
77
+ return {
78
+ slug: "get-a-quote",
79
+ defaultLocale: "en",
80
+ steps: [
81
+ {
82
+ key: "contact",
83
+ type: "field",
84
+ copy: { title: { en: "Let's get started" } },
85
+ fields: [
86
+ {
87
+ name: "email",
88
+ inputType: "email",
89
+ required: true,
90
+ ...(opts.tagged ? { mapsTo: "email" } : {}),
91
+ copy: { label: { en: "Email" } },
92
+ },
93
+ ...(opts.secondEmail
94
+ ? [
95
+ {
96
+ name: "backup",
97
+ inputType: "email",
98
+ ...(opts.tagged ? { mapsTo: "email" } : {}),
99
+ copy: { label: { en: "Backup" } },
100
+ },
101
+ ]
102
+ : []),
103
+ ],
104
+ },
105
+ ],
106
+ ...(opts.thankYou
107
+ ? { thankYou: { default: "flow-thanks", bySegment: { qualified: "flow-thanks-qualified" } } }
108
+ : {}),
109
+ } as FlowConfig;
110
+ }
111
+
112
+ function ok(response: Record<string, unknown>) {
113
+ return {
114
+ ok: true as const,
115
+ response: {
116
+ sessionId: "s",
117
+ landed: "contact",
118
+ nextStepKey: null,
119
+ completed: true,
120
+ ...response,
121
+ },
122
+ } as never;
123
+ }
124
+
125
+ async function walk(cfg: FlowConfig): Promise<void> {
126
+ render(<FlowStepperClient config={cfg} locale="en" slug="get-a-quote" />);
127
+ await userEvent.click(screen.getByTestId("mock-submit"));
128
+ }
129
+
130
+ beforeEach(() => {
131
+ mockPush.mockClear();
132
+ mockSubmit.mockReset();
133
+ mockLink.mockReset();
134
+ mockLink.mockResolvedValue(HREF);
135
+ steps = [{ stepKey: "contact", values: { email: EMAIL } }];
136
+ window.sessionStorage.clear();
137
+ });
138
+
139
+ describe("the finished panel (no thankYou block)", () => {
140
+ it("shows the prefilled link inside a Replay-blocked element", async () => {
141
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
142
+
143
+ await walk(config({ tagged: true, thankYou: false }));
144
+
145
+ const link = await screen.findByTestId("flow-account-invite");
146
+ expect(link).toHaveAttribute("href", HREF);
147
+ expect(link).toHaveTextContent("trackInAccount");
148
+ expect(link.closest("[data-sentry-block]")).not.toBeNull();
149
+ // Inside the finished card, not a sibling after the frame (#8771 design review).
150
+ expect(link.closest('[data-testid="mock-completed-card"]')).not.toBeNull();
151
+ expect(mockLink).toHaveBeenCalledTimes(1);
152
+ expect(mockLink).toHaveBeenCalledWith(EMAIL);
153
+ });
154
+
155
+ it("only an email-tagged field counts", async () => {
156
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
157
+
158
+ await walk(config({ tagged: false, thankYou: false }));
159
+
160
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalled());
161
+ expect(mockLink).not.toHaveBeenCalled();
162
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
163
+ });
164
+
165
+ it("uses the step's first tagged email field", async () => {
166
+ steps = [{ stepKey: "contact", values: { email: "first@example.com", backup: "second@example.com" } }];
167
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
168
+
169
+ await walk(config({ tagged: true, thankYou: false, secondEmail: true }));
170
+
171
+ await screen.findByTestId("flow-account-invite");
172
+ expect(mockLink).toHaveBeenCalledWith("first@example.com");
173
+ });
174
+
175
+ it.each([
176
+ ["no next", { }],
177
+ ["a step that does not complete", { completed: false, next: { completion: "invite" } }],
178
+ ])("%s: no action call and no link", async (_name, response) => {
179
+ mockSubmit.mockResolvedValue(ok(response));
180
+
181
+ await walk(config({ tagged: true, thankYou: false }));
182
+
183
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalled());
184
+ expect(mockLink).not.toHaveBeenCalled();
185
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
186
+ });
187
+
188
+ it("an action answer of null shows no link", async () => {
189
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
190
+ mockLink.mockResolvedValue(null);
191
+
192
+ await walk(config({ tagged: true, thankYou: false }));
193
+
194
+ await waitFor(() => expect(mockLink).toHaveBeenCalled());
195
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
196
+ });
197
+
198
+ it("a conflict forgets the email: a later completing step with no email field asks for no link", async () => {
199
+ mockSubmit
200
+ .mockResolvedValueOnce(ok({ completed: false }))
201
+ .mockResolvedValueOnce({ ok: false, kind: "step_conflict", message: "x" } as never)
202
+ .mockResolvedValueOnce(ok({ next: { completion: "invite" } }));
203
+ steps = [
204
+ { stepKey: "contact", values: { email: EMAIL } },
205
+ { stepKey: "contact", values: { email: EMAIL } },
206
+ { stepKey: "other", values: { note: "hi" } },
207
+ ];
208
+
209
+ await walk(config({ tagged: true, thankYou: false }));
210
+
211
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(3));
212
+ expect(mockLink).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+
216
+ describe("the /thanks hand-off (a thankYou block)", () => {
217
+ it("writes one sessionStorage item and pushes a URL with no email in it", async () => {
218
+ mockSubmit.mockResolvedValue(ok({ segment: "qualified", next: { completion: "invite" } }));
219
+
220
+ await walk(config({ tagged: true, thankYou: true }));
221
+
222
+ await waitFor(() => expect(mockPush).toHaveBeenCalled());
223
+ expect(JSON.parse(window.sessionStorage.getItem("ailk:account-invite") ?? "null")).toEqual({
224
+ thanks: "flow-thanks-qualified",
225
+ href: HREF,
226
+ });
227
+ const url = mockPush.mock.calls[0]?.[0] as string;
228
+ expect(url).toBe("/en/thanks/flow-thanks-qualified");
229
+ expect(url).not.toContain("@");
230
+ expect(screen.queryByTestId("flow-account-invite")).toBeNull();
231
+ });
232
+
233
+ it("clears an earlier walk's hand-off when this completion has no link", async () => {
234
+ window.sessionStorage.setItem(
235
+ "ailk:account-invite",
236
+ JSON.stringify({ thanks: "flow-thanks", href: HREF }),
237
+ );
238
+ mockSubmit.mockResolvedValue(ok({}));
239
+
240
+ await walk(config({ tagged: true, thankYou: true }));
241
+
242
+ await waitFor(() => expect(mockPush).toHaveBeenCalled());
243
+ expect(window.sessionStorage.getItem("ailk:account-invite")).toBeNull();
244
+ });
245
+
246
+ it("a sessionStorage that throws still navigates", async () => {
247
+ mockSubmit.mockResolvedValue(ok({ next: { completion: "invite" } }));
248
+ const spy = jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
249
+ throw new Error("denied");
250
+ });
251
+
252
+ await walk(config({ tagged: true, thankYou: true }));
253
+
254
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/en/thanks/flow-thanks"));
255
+ spy.mockRestore();
256
+ });
257
+ });
@@ -0,0 +1,69 @@
1
+ // oss: false
2
+ /**
3
+ * #8771 AC-4 (the action) — accountInviteLinkAction builds #8757's link on the
4
+ * server and answers null when it cannot.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const mockGetLocale = jest.fn();
10
+ jest.mock("next-intl/server", () => ({
11
+ getLocale: (...args: unknown[]) => mockGetLocale(...args),
12
+ }));
13
+
14
+ const mockInviteHref = jest.fn();
15
+ // eslint-disable-next-line no-restricted-syntax -- accountInviteHref reads the generated topology (build output); this file asserts the action's own decision
16
+ jest.mock("../../../../../lib/account-invite", () => ({
17
+ accountInviteHref: (...args: unknown[]) => mockInviteHref(...args),
18
+ }));
19
+
20
+ import { accountInviteLinkAction } from "../account-invite.actions";
21
+
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ mockGetLocale.mockResolvedValue("fr");
25
+ mockInviteHref.mockImplementation(
26
+ (locale: string, email: string) => `/${locale}/sign-in?email=${email}`,
27
+ );
28
+ });
29
+
30
+ describe("accountInviteLinkAction", () => {
31
+ it("returns what accountInviteHref returns for the request's locale", async () => {
32
+ await expect(accountInviteLinkAction("jane@example.com")).resolves.toBe(
33
+ "/fr/sign-in?email=jane@example.com",
34
+ );
35
+ expect(mockInviteHref).toHaveBeenCalledWith("fr", "jane@example.com");
36
+ });
37
+
38
+ it.each(["nope", "a\r\n@b.example", "a b@example.com"])(
39
+ "refuses %j",
40
+ async (email) => {
41
+ await expect(accountInviteLinkAction(email)).resolves.toBeNull();
42
+ expect(mockInviteHref).not.toHaveBeenCalled();
43
+ },
44
+ );
45
+
46
+ it("answers null when the locale cannot be read", async () => {
47
+ mockGetLocale.mockRejectedValue(new Error("no request scope"));
48
+ await expect(
49
+ accountInviteLinkAction("jane@example.com"),
50
+ ).resolves.toBeNull();
51
+ });
52
+
53
+ it("answers null for a site with no sign-in", async () => {
54
+ mockInviteHref.mockReturnValue(null);
55
+ await expect(
56
+ accountInviteLinkAction("jane@example.com"),
57
+ ).resolves.toBeNull();
58
+ });
59
+
60
+ it("makes no API call, and carries the oss marker on its first line", () => {
61
+ const source = readFileSync(
62
+ join(__dirname, "..", "account-invite.actions.ts"),
63
+ "utf8",
64
+ );
65
+ expect(source.split("\n")[0]).toBe("// oss: false");
66
+ expect(source).not.toMatch(/\bfetch\(/);
67
+ expect(source).not.toContain("@working-theory/api-client");
68
+ });
69
+ });
@@ -0,0 +1,35 @@
1
+ // oss: false
2
+ "use server";
3
+
4
+ /**
5
+ * accountInviteLinkAction — the walk's "Track this in your account" link
6
+ * (#8771 D5). The walk's client holds the email the visitor typed; this builds
7
+ * the sign-in link that starts with that email prefilled, using #8757's
8
+ * `accountInviteHref`. It is a Server Action, not client code, because
9
+ * `accountInviteHref` reads the generated topology module, which lazily
10
+ * imports `../auth` and must never reach a client bundle.
11
+ *
12
+ * It makes no API call and writes nothing — the invite email is sent by the
13
+ * API at completion. No MCP twin: it builds a link from the visitor's own
14
+ * email for a browser to follow (`WEB_ONLY_ACTIONS` in
15
+ * `apps/mcp/__tests__/parity.test.ts`).
16
+ *
17
+ * Answers `null` for an email the hint schema refuses, a locale that cannot be
18
+ * read, or a site with no sign-in — the walk then shows no link.
19
+ */
20
+ import { accountEmailHintSchema } from "@working-theory/validation";
21
+ import { getLocale } from "next-intl/server";
22
+
23
+ import { accountInviteHref } from "../../../../lib/account-invite";
24
+
25
+ export async function accountInviteLinkAction(
26
+ email: string,
27
+ ): Promise<string | null> {
28
+ const hint = accountEmailHintSchema.safeParse(email);
29
+ if (!hint.success) return null;
30
+ try {
31
+ return accountInviteHref(await getLocale(), hint.data);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
@@ -80,7 +80,7 @@ describe("ForPage — snapshot regression", () => {
80
80
  });
81
81
  const { container } = render(element);
82
82
  expect(container.innerHTML).toMatchInlineSnapshot(
83
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><div><h1>Use Cases</h1><p data-testid="for-description" class="mt-block-tight text-lead">How teams use our platform.</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-colors 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 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 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 transition-colors 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 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 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
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><div><h1>Use Cases</h1><p data-testid="for-description" class="mt-block-tight text-lead">How teams use our platform.</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 [&amp;>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 [&amp;>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>"`,
84
84
  );
85
85
  });
86
86
  });
@@ -58,7 +58,7 @@ describe("how-to/[slug] — snapshot regression", () => {
58
58
  });
59
59
  const { container } = render(element);
60
60
  expect(container.innerHTML).toMatchInlineSnapshot(`
61
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel.","mainEntity":{"@context":"https://schema.org","@type":"HowTo","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"How-To Guides","item":"http://localhost:3000/en/how-to"},{"@type":"ListItem","position":3,"name":"Deploy AILK to Vercel","item":"http://localhost:3000/en/how-to/deploy-ailk-vercel"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Deploy AILK to Vercel</h1><p data-testid="how-to-description" class="mt-block-tight text-lead">Step-by-step guide to deploying AI Launch Kit on Vercel.</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-colors 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 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 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 transition-colors 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 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 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 class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Steps
61
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel.","mainEntity":{"@context":"https://schema.org","@type":"HowTo","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"How-To Guides","item":"http://localhost:3000/en/how-to"},{"@type":"ListItem","position":3,"name":"Deploy AILK to Vercel","item":"http://localhost:3000/en/how-to/deploy-ailk-vercel"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Deploy AILK to Vercel</h1><p data-testid="how-to-description" class="mt-block-tight text-lead">Step-by-step guide to deploying AI Launch Kit on Vercel.</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 [&amp;>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 [&amp;>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 class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Steps
62
62
 
63
63
  Body content.</div></div></article></div></div></div></main>"
64
64
  `);