create-ailk 0.19.1 → 0.21.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 (199) hide show
  1. package/README.md +84 -7
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/cli.js +35 -0
  7. package/dist/copy.d.ts +41 -0
  8. package/dist/copy.js +87 -21
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/lib/design-checks.d.ts +6 -1
  12. package/dist/lib/design-checks.js +23 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  14. package/dist/lib/fetch-template-snapshot.js +205 -0
  15. package/dist/lib/merge-three-way.d.ts +31 -0
  16. package/dist/lib/merge-three-way.js +65 -0
  17. package/dist/module-architecture.js +55 -2
  18. package/dist/programmatic.d.ts +7 -1
  19. package/dist/programmatic.js +76 -22
  20. package/dist/site-roster.d.ts +4 -2
  21. package/dist/site-roster.js +7 -5
  22. package/dist/spoke-checkout.d.ts +7 -0
  23. package/dist/spoke-checkout.js +8 -1
  24. package/dist/starter-grid.js +16 -0
  25. package/dist/surfaces.js +78 -2
  26. package/dist/sync-routes.d.ts +28 -0
  27. package/dist/sync-routes.js +123 -1
  28. package/dist/template-record.d.ts +59 -0
  29. package/dist/template-record.js +183 -0
  30. package/dist/update.d.ts +53 -0
  31. package/dist/update.js +606 -0
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  51. package/templates/.claude/hooks/architecture-check.sh +8 -0
  52. package/templates/.claude/rules/loop-contract.md +129 -0
  53. package/templates/.claude/settings.json +3 -2
  54. package/templates/.env.example +13 -0
  55. package/templates/CLAUDE.md +1 -1
  56. package/templates/CONVENTIONS.md +3 -2
  57. package/templates/README.md +26 -25
  58. package/templates/_gitignore +162 -0
  59. package/templates/apps/api/CLAUDE.md +18 -10
  60. package/templates/apps/api/jest.config.cjs +1 -0
  61. package/templates/apps/api/package.json +2 -1
  62. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  63. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  64. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  65. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  66. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  67. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  68. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  69. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  70. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  71. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  72. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  74. package/templates/apps/api/src/middleware/auth.ts +8 -4
  75. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  76. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  77. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  78. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  79. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  80. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  81. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  82. package/templates/apps/api/src/server.ts +14 -2
  83. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  84. package/templates/apps/api/src/services/audit-log.ts +72 -10
  85. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  86. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  87. package/templates/apps/api/src/services/project-listings.ts +6 -1
  88. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  89. package/templates/apps/api/vercel.json +1 -1
  90. package/templates/apps/mcp/CLAUDE.md +19 -7
  91. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
  92. package/templates/apps/mcp/jest.config.cjs +1 -0
  93. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  94. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  95. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  96. package/templates/apps/web/CLAUDE.md +26 -1
  97. package/templates/apps/web/_gitignore +6 -0
  98. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  99. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  100. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  111. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  115. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  118. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  124. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  125. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  129. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  132. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  133. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  134. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  135. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  137. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  138. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  139. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
  148. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  149. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  150. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  151. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/layout.tsx +46 -4
  154. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  155. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  156. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  157. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  158. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  160. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  161. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  162. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  163. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  164. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  165. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  166. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  167. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  168. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  169. package/templates/apps/web/jest.config.cjs +4 -0
  170. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  171. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  172. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  173. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
  174. package/templates/apps/web/lib/account-config.ts +31 -16
  175. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  176. package/templates/apps/web/lib/deck-page.ts +186 -0
  177. package/templates/apps/web/lib/relay.ts +20 -0
  178. package/templates/apps/web/messages/en.json +83 -9
  179. package/templates/apps/web/next.config.mjs +8 -2
  180. package/templates/database/CHANGELOG.md +48 -0
  181. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  182. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/__tests__/seed.test.ts +39 -2
  186. package/templates/database/_gitignore +8 -0
  187. package/templates/database/inbox/schema.prisma +38 -12
  188. package/templates/database/jest.config.cjs +1 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/database/seed.ts +29 -0
  196. package/templates/project.yaml +6 -3
  197. package/templates/tools/jest/pin-timezone.cjs +34 -0
  198. package/versions.json +35 -35
  199. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,226 @@
1
+ /**
2
+ * /[locale]/account/privacy — User settings › Privacy (#8438 D14, NEW).
3
+ *
4
+ * RSC, outside the account layout's SignedInShell (#8430), in the member's
5
+ * settings frame (D7) — standalone, the Admin's shape. In DOM order:
6
+ *
7
+ * 1. account-privacy-summary — one line with a link to the site's
8
+ * privacy policy.
9
+ * 2. account-privacy-cookies — the current cookie-banner choice, read
10
+ * from the request's OWN `ailk-consent` cookie (never the ledger —
11
+ * the cookie stays the operative switch, C3), and a Change link
12
+ * (moved here from Notifications, #8438 D15) to
13
+ * `/privacy#cookie-settings`.
14
+ * 3. account-privacy-consents — one `ConsentSwitchList` row per
15
+ * `area: "data"` purpose, or the "no other data-use consents" line.
16
+ * 4. account-privacy-agreements — a read-only `AgreementList` of every
17
+ * agreement (`service_terms`, `account_terms`, …), or "None yet".
18
+ * 5. account-deletion — moved unchanged from General (#8438 D16).
19
+ *
20
+ * Sections 3 and 4 read `GET /v1/member/consents`; a failed read shows one
21
+ * line in their place and sections 1, 2 and 5 still render (D14).
22
+ */
23
+
24
+ import { member } from "@working-theory/api-client";
25
+ import {
26
+ AccountSettingsFrame,
27
+ AccountSettingsPane,
28
+ AgreementList,
29
+ ConsentSwitchList,
30
+ } from "@working-theory/templates/account";
31
+ import { localeHref } from "@working-theory/ui";
32
+ import { Heading, Link, Text } from "@working-theory/ui/primitives";
33
+ import type { Metadata } from "next";
34
+ import { cookies } from "next/headers";
35
+ import { getTranslations } from "next-intl/server";
36
+ import type { ReactElement } from "react";
37
+
38
+ import { accountSession } from "../account-data";
39
+ import { DeletionRequest } from "../general/deletion-request";
40
+ import { loadProfileSource } from "../general/profile-source";
41
+ import { memberSettingsFrame, type Translate } from "../settings-frame";
42
+
43
+ import { setConsentAction } from "./actions";
44
+
45
+ /** The cookie banner's own choice cookie (`layout.tsx`'s `CONSENT_COOKIE_NAME`, #5557). */
46
+ const CONSENT_COOKIE_NAME = "ailk-consent";
47
+
48
+ interface Props {
49
+ params: Promise<{ locale: string }>;
50
+ }
51
+
52
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
53
+ const { locale } = await params;
54
+ const t = await getTranslations({ locale, namespace: "account" });
55
+ return { title: t("settings.privacy.title"), description: t("settings.privacy.lead") };
56
+ }
57
+
58
+ export default async function AccountPrivacyPage({ params }: Props): Promise<ReactElement> {
59
+ const { locale } = await params;
60
+ const t = await getTranslations({ locale, namespace: "account" });
61
+ const session = await accountSession(locale);
62
+ const frame = memberSettingsFrame(locale, t as unknown as Translate, "privacy");
63
+
64
+ // Unreachable behind the (authed) gate; a session that expired between
65
+ // the layout and the page renders the pane's title alone.
66
+ if (!session) {
67
+ return (
68
+ <AccountSettingsFrame {...frame} name="">
69
+ <AccountSettingsPane
70
+ id="account-settings-privacy"
71
+ title={t("settings.privacy.title")}
72
+ lead={t("settings.privacy.lead")}
73
+ >
74
+ {null}
75
+ </AccountSettingsPane>
76
+ </AccountSettingsFrame>
77
+ );
78
+ }
79
+
80
+ const { ctx } = session;
81
+ const name = ctx.user.name || ctx.user.email;
82
+ const source = await loadProfileSource(ctx);
83
+
84
+ let cookieChoice: "accepted" | "declined" | null;
85
+ try {
86
+ const value = (await cookies()).get(CONSENT_COOKIE_NAME)?.value;
87
+ cookieChoice = value === "accepted" || value === "declined" ? value : null;
88
+ } catch {
89
+ cookieChoice = null;
90
+ }
91
+
92
+ let dataRows: { id: string; label: string; description?: string; granted: boolean; dateLine?: string }[] = [];
93
+ let agreementRows: { id: string; statement: string; version: number; acceptedLine?: string }[] = [];
94
+ let consentsAvailable = true;
95
+ try {
96
+ const res = await member.consents(ctx.init, { locale });
97
+ if (res.ok) {
98
+ dataRows = res.body.purposes
99
+ .filter((purpose) => purpose.area === "data")
100
+ .map((purpose) => ({
101
+ id: purpose.id,
102
+ label: purpose.statement,
103
+ granted: purpose.effective,
104
+ ...(purpose.description ? { description: purpose.description } : {}),
105
+ ...(purpose.state
106
+ ? {
107
+ dateLine: purpose.state.granted
108
+ ? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
109
+ : t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
110
+ }
111
+ : {}),
112
+ }));
113
+ agreementRows = res.body.purposes
114
+ .filter((purpose) => purpose.kind === "agreement")
115
+ .map((purpose) => ({
116
+ id: purpose.id,
117
+ statement: purpose.statement,
118
+ version: purpose.policyVersion,
119
+ ...(purpose.state?.granted
120
+ ? { acceptedLine: t("settings.privacy.accepted", { date: formatDate(purpose.state.at, locale) }) }
121
+ : {}),
122
+ }));
123
+ } else {
124
+ consentsAvailable = false;
125
+ }
126
+ } catch {
127
+ consentsAvailable = false;
128
+ }
129
+
130
+ return (
131
+ <AccountSettingsFrame {...frame} name={name}>
132
+ <AccountSettingsPane
133
+ id="account-settings-privacy"
134
+ title={t("settings.privacy.title")}
135
+ lead={t("settings.privacy.lead")}
136
+ >
137
+ <div data-testid="account-privacy-summary" className="flex flex-wrap items-baseline gap-1">
138
+ <Text as="span" variant="body-sm">
139
+ {t("settings.privacy.summary")}
140
+ </Text>
141
+ <Link href={localeHref(locale, "/privacy")} data-testid="account-privacy-policy">
142
+ {t("settings.privacy.policyLink")}
143
+ </Link>
144
+ </div>
145
+
146
+ <div data-testid="account-privacy-cookies" className="flex flex-col gap-1">
147
+ <Heading level={2} visual={4} className="!text-body-sm !font-medium">
148
+ {t("settings.privacy.cookiesTitle")}
149
+ </Heading>
150
+ <div className="flex flex-wrap items-baseline gap-3">
151
+ <Text as="span" variant="body-sm" tone="muted">
152
+ {cookieChoice === "accepted"
153
+ ? t("settings.privacy.cookiesAccepted")
154
+ : cookieChoice === "declined"
155
+ ? t("settings.privacy.cookiesDeclined")
156
+ : t("settings.privacy.cookiesUnset")}
157
+ </Text>
158
+ <Link
159
+ href={localeHref(locale, "/privacy#cookie-settings")}
160
+ className="inline-flex min-h-6 items-center"
161
+ data-testid="account-cookie-settings"
162
+ >
163
+ {t("settings.privacy.cookiesChange")}
164
+ </Link>
165
+ </div>
166
+ </div>
167
+
168
+ {consentsAvailable ? (
169
+ <>
170
+ <div data-testid="account-privacy-consents" className="flex flex-col gap-2">
171
+ <Heading level={2} visual={4} className="!text-body-sm !font-medium">
172
+ {t("settings.privacy.consentsTitle")}
173
+ </Heading>
174
+ <ConsentSwitchList
175
+ rows={dataRows}
176
+ onChange={setConsentAction}
177
+ emptyLine={t("settings.privacy.consentsEmpty")}
178
+ testIdPrefix="account-privacy-data-consent"
179
+ />
180
+ </div>
181
+
182
+ <div data-testid="account-privacy-agreements" className="flex flex-col gap-2">
183
+ <Heading level={2} visual={4} className="!text-body-sm !font-medium">
184
+ {t("settings.privacy.agreementsTitle")}
185
+ </Heading>
186
+ <AgreementList
187
+ rows={agreementRows}
188
+ notAcceptedLine={t("settings.privacy.agreementsEmpty")}
189
+ emptyLine={t("settings.privacy.agreementsEmpty")}
190
+ testIdPrefix="account-privacy-agreement"
191
+ />
192
+ </div>
193
+ </>
194
+ ) : (
195
+ <span data-testid="account-privacy-unavailable">
196
+ <Text as="p" variant="body-sm" tone="muted">
197
+ {t("settings.privacy.unavailable")}
198
+ </Text>
199
+ </span>
200
+ )}
201
+
202
+ <div data-testid="account-deletion">
203
+ <DeletionRequest
204
+ via={source.via}
205
+ supportHref={localeHref(locale, "/account/support")}
206
+ labels={{
207
+ lead: t("settings.privacy.deletionLead"),
208
+ action: t("settings.privacy.deletion"),
209
+ sentSite: t("settings.privacy.deletionSentSite"),
210
+ sentHub: t("settings.privacy.deletionSentHub"),
211
+ unverified: t("settings.privacy.deletionUnverified"),
212
+ unavailable: t("settings.privacy.deletionUnavailable"),
213
+ error: t("settings.privacy.deletionError"),
214
+ viaSupport: t("settings.privacy.deletionViaSupport"),
215
+ supportLink: t("settings.privacy.deletionSupportLink"),
216
+ }}
217
+ />
218
+ </div>
219
+ </AccountSettingsPane>
220
+ </AccountSettingsFrame>
221
+ );
222
+ }
223
+
224
+ function formatDate(iso: string, locale: string): string {
225
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
226
+ }
@@ -25,7 +25,7 @@
25
25
  import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
26
26
  import { localeHref } from "@working-theory/ui";
27
27
  import { Heading, Icon, Text } from "@working-theory/ui/primitives";
28
- import { Bell, CreditCard, ShieldCheck, UserRound } from "lucide-react";
28
+ import { Bell, CreditCard, Lock, ShieldCheck, UserRound } from "lucide-react";
29
29
  import type { ReactElement, ReactNode } from "react";
30
30
 
31
31
  /** The `next-intl` translator General/Security/Notifications/Billing each
@@ -36,12 +36,14 @@ import type { ReactElement, ReactNode } from "react";
36
36
  * own `t` with `as unknown as Translate`. */
37
37
  export type Translate = (key: string, values?: Record<string, unknown>) => string;
38
38
 
39
- /** User settings' tabs, in the reference's order (#8339 D7) — each one a
40
- * route that renders outside the account shell (#8430, #8441). */
39
+ /** User settings' tabs, in the reference's order (#8339 D7; #8438 D14 adds
40
+ * Privacy before Billing) — each one a route that renders outside the
41
+ * account shell (#8430, #8441). */
41
42
  export const SETTINGS_ROUTES = [
42
43
  { key: "general", path: "/account/general", titleKey: "settings.general.title", icon: UserRound },
43
44
  { key: "security", path: "/account/security", titleKey: "settings.security.title", icon: ShieldCheck },
44
45
  { key: "notifications", path: "/account/notifications", titleKey: "settings.notifications.title", icon: Bell },
46
+ { key: "privacy", path: "/account/privacy", titleKey: "settings.privacy.title", icon: Lock },
45
47
  { key: "billing", path: "/account/billing", titleKey: "settings.billing.title", icon: CreditCard },
46
48
  ] as const;
47
49
 
@@ -0,0 +1,46 @@
1
+ // A person's own page (#8650 D3). Reads the Person MDX for the given slug and
2
+ // emits a JSON-LD <script>. Unknown slugs 404 through renderPage. Renders
3
+ // `sections[]` when the frontmatter carries them (the /for composed
4
+ // pattern), MDX prose otherwise.
5
+ import {
6
+ getMdxSource as defaultGetMdxSource,
7
+ renderPage,
8
+ renderPageMetadata,
9
+ } from "@working-theory/page-renderer";
10
+ import type { Metadata } from "next";
11
+ import type { ReactElement } from "react";
12
+
13
+ interface Props {
14
+ params: Promise<{ locale: string; slug: string }>;
15
+ getMdxSource?: typeof defaultGetMdxSource;
16
+ }
17
+
18
+ export async function generateMetadata({
19
+ params,
20
+ }: Pick<Props, "params">): Promise<Metadata> {
21
+ const { locale, slug } = await params;
22
+ return renderPageMetadata({ type: "Person", slug, locale });
23
+ }
24
+
25
+ export default async function Page({
26
+ params,
27
+ getMdxSource = defaultGetMdxSource,
28
+ }: Props): Promise<ReactElement> {
29
+ const { locale, slug } = await params;
30
+ // Read once to pick the body mode. A null source still goes to renderPage,
31
+ // which owns the unknown-slug branch (#7537).
32
+ const source = await getMdxSource("Person", slug, locale);
33
+
34
+ const composed =
35
+ source !== null &&
36
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
37
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
38
+
39
+ return renderPage({
40
+ type: "Person",
41
+ slug,
42
+ locale,
43
+ ...(composed ? {} : { bodyMode: "mdx-body" as const }),
44
+ getMdxSource: async () => source,
45
+ });
46
+ }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * The about routes (#8650 spec, AC-1 through AC-4):
3
+ *
4
+ * - AC-1: `/about/<slug>` renders a `ProfilePage` whose `mainEntity` is a
5
+ * `Person`, valid under the schema validator, with an About breadcrumb; an
6
+ * unknown slug 404s through `renderPage`.
7
+ * - AC-2: `/about` lists every `Person` page as a generated `team` section,
8
+ * each member linking to its own `/about/<slug>` page, before a trailing
9
+ * `cta` section.
10
+ * - AC-3: the routes map, sitemap and `llms.txt` all advertise a `Person`
11
+ * page at `/about/<slug>`, never a bare `/<slug>`.
12
+ * - AC-4: with zero `Person` pages, `/about` renders byte-identical to
13
+ * calling `renderPage` directly (C5 — a fresh scaffold is such a site).
14
+ *
15
+ * MDX sources are inline stubs (no fs reads) for AC-2/AC-4, and a real
16
+ * `createMdxAdapter` over a temp content root for AC-1 (the spec's own
17
+ * method) — both run unchanged inside a scaffold.
18
+ */
19
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { render } from "@testing-library/react";
24
+ import { createMdxAdapter } from "@working-theory/content-adapters";
25
+ import * as pageRenderer from "@working-theory/page-renderer";
26
+ import { validatePageGraph } from "@working-theory/schema-validator";
27
+ import React from "react";
28
+
29
+ jest.mock("next/navigation", () => ({
30
+ __esModule: true,
31
+ notFound: jest.fn(() => {
32
+ throw new Error("NEXT_NOT_FOUND");
33
+ }),
34
+ }));
35
+
36
+ jest.mock("next-mdx-remote/rsc", () => ({
37
+ __esModule: true,
38
+ MDXRemote: ({ source }: { source: string }) =>
39
+ React.createElement("div", { "data-testid": "mdx-rendered" }, source),
40
+ }));
41
+
42
+ import AboutPage from "../page";
43
+ import AboutPersonPage from "../[slug]/page";
44
+ import { createSitemapHandler } from "../../../sitemap.xml/route";
45
+ import { createLlmsHandler } from "../../../llms.txt/route";
46
+ import type { ContentAdapter } from "@working-theory/content-adapters";
47
+
48
+ type JsonLd = Record<string, unknown>;
49
+
50
+ function jsonLdScripts(container: HTMLElement): JsonLd[] {
51
+ return [
52
+ ...container.querySelectorAll("script[type='application/ld+json']"),
53
+ ].map((s) => JSON.parse(s.textContent ?? "") as JsonLd);
54
+ }
55
+
56
+ describe("about/[slug] — a person's own page (AC-1)", () => {
57
+ let root: string;
58
+
59
+ beforeAll(() => {
60
+ root = mkdtempSync(join(tmpdir(), "ailk-8650-"));
61
+ mkdirSync(join(root, "en", "Person"), { recursive: true });
62
+ writeFileSync(
63
+ join(root, "en", "Person", "sam-henry.mdx"),
64
+ [
65
+ "---",
66
+ "type: Person",
67
+ "slug: sam-henry",
68
+ "title: Sam Henry",
69
+ "description: Co-founder and partner.",
70
+ "schema:",
71
+ " jobTitle: Partner",
72
+ "---",
73
+ "",
74
+ "Sam Henry, co-founder and partner.",
75
+ "",
76
+ ].join("\n"),
77
+ );
78
+ });
79
+
80
+ afterAll(() => {
81
+ rmSync(root, { recursive: true, force: true });
82
+ });
83
+
84
+ it("emits one ProfilePage graph whose Person carries @id #person, valid under the schema validator", async () => {
85
+ const adapter = createMdxAdapter({ contentRoot: root, defaultLocale: "en" });
86
+ const element = await AboutPersonPage({
87
+ params: Promise.resolve({ locale: "en", slug: "sam-henry" }),
88
+ getMdxSource: (type, slug, locale) =>
89
+ adapter.getPageSource(type, slug, locale),
90
+ });
91
+ const { container } = render(element);
92
+ const scripts = jsonLdScripts(container);
93
+ expect(scripts).toHaveLength(1);
94
+ const graph = scripts[0]!["@graph"] as JsonLd[];
95
+ expect(validatePageGraph(graph as never, "Person")).toEqual(
96
+ expect.objectContaining({ valid: true }),
97
+ );
98
+
99
+ const profile = graph.find((b) => b["@type"] === "ProfilePage")!;
100
+ const person = profile["mainEntity"] as JsonLd;
101
+ expect(person["@type"]).toBe("Person");
102
+ expect(person["@id"]).toBe("#person");
103
+ expect(person["name"]).toBe("Sam Henry");
104
+ expect(person["jobTitle"]).toBe("Partner");
105
+
106
+ const breadcrumb = graph.find((b) => b["@type"] === "BreadcrumbList")!;
107
+ const items = breadcrumb["itemListElement"] as Array<{ item: string }>;
108
+ expect(items[1]!.item).toMatch(/\/en\/about$/);
109
+ });
110
+
111
+ it("404s an unknown slug through renderPage, with no translations namespace", async () => {
112
+ const renderPageSpy = jest.spyOn(pageRenderer, "renderPage");
113
+ const adapter = createMdxAdapter({ contentRoot: root, defaultLocale: "en" });
114
+ await expect(
115
+ AboutPersonPage({
116
+ params: Promise.resolve({ locale: "en", slug: "nobody" }),
117
+ getMdxSource: (type, slug, locale) =>
118
+ adapter.getPageSource(type, slug, locale),
119
+ }),
120
+ ).rejects.toThrow("NEXT_NOT_FOUND");
121
+ expect(renderPageSpy).toHaveBeenCalledTimes(1);
122
+ expect(
123
+ renderPageSpy.mock.calls[0]![0].translationsNamespace,
124
+ ).toBeUndefined();
125
+ renderPageSpy.mockRestore();
126
+ });
127
+ });
128
+
129
+ const ABOUT_WITH_CTA = {
130
+ frontmatter: {
131
+ type: "AboutPage",
132
+ slug: "about",
133
+ title: "About",
134
+ description: "About us.",
135
+ sections: [
136
+ {
137
+ id: "intro",
138
+ type: "content-block",
139
+ data: { headline: "About us", body: "We build things." },
140
+ },
141
+ {
142
+ id: "closing",
143
+ type: "cta",
144
+ data: {
145
+ headline: "Let's talk",
146
+ primaryCta: { label: "Contact us", href: "/contact" },
147
+ },
148
+ },
149
+ ],
150
+ },
151
+ body: "",
152
+ };
153
+
154
+ const SAM_SOURCE = {
155
+ frontmatter: {
156
+ type: "Person",
157
+ slug: "sam-henry",
158
+ title: "Sam Henry",
159
+ description: "Co-founder and partner.",
160
+ schema: { jobTitle: "Partner" },
161
+ },
162
+ body: "",
163
+ };
164
+
165
+ const WENDI_SOURCE = {
166
+ frontmatter: {
167
+ type: "Person",
168
+ slug: "wendi-henry",
169
+ title: "About Wendi",
170
+ description: "Co-founder and partner.",
171
+ schema: { name: "Wendi Henry" },
172
+ },
173
+ body: "",
174
+ };
175
+
176
+ describe("about — the generated people list (AC-2)", () => {
177
+ it("lists every Person page as one team section, before the closing cta, linking to /about/<slug>", async () => {
178
+ const getMdxSource = jest.fn(
179
+ async (type: string, slug: string): Promise<unknown> => {
180
+ if (type === "AboutPage") return ABOUT_WITH_CTA;
181
+ if (type === "Person" && slug === "sam-henry") return SAM_SOURCE;
182
+ if (type === "Person" && slug === "wendi-henry") return WENDI_SOURCE;
183
+ return null;
184
+ },
185
+ );
186
+ const getMdxSlugs = jest
187
+ .fn()
188
+ .mockResolvedValue(["wendi-henry", "sam-henry", "ghost-member"]);
189
+
190
+ const element = await AboutPage({
191
+ params: Promise.resolve({ locale: "en" }),
192
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double over a narrower DI surface than the real multi-type loader
193
+ getMdxSource: getMdxSource as any,
194
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double
195
+ getMdxSlugs: getMdxSlugs as any,
196
+ });
197
+ const { container } = render(element);
198
+
199
+ const cards = container.querySelectorAll('[data-testid="team-member-card"]');
200
+ // The null-source "ghost-member" slug is skipped — two cards, not three.
201
+ expect(cards).toHaveLength(2);
202
+
203
+ const links = container.querySelectorAll('[data-testid^="member-page-link-"]');
204
+ const hrefs = [...links].map((l) => l.getAttribute("href"));
205
+ expect(hrefs).toEqual(["/about/sam-henry", "/about/wendi-henry"]);
206
+ for (const link of links) {
207
+ expect(link.textContent).toBe("View profile");
208
+ }
209
+
210
+ // Wendi's card shows schema.name, not title; Sam's shows title (no
211
+ // schema.name override) with his jobTitle as the role.
212
+ expect(cards[0]!.textContent).toContain("Sam Henry");
213
+ expect(cards[0]!.textContent).toContain("Partner");
214
+ expect(cards[1]!.textContent).toContain("Wendi Henry");
215
+ expect(cards[1]!.textContent).not.toContain("About Wendi");
216
+ expect(cards[1]!.textContent).not.toContain("Partner");
217
+
218
+ // The team section sits before the closing cta section in document order.
219
+ const html = container.innerHTML;
220
+ expect(html.indexOf("team-member-card")).toBeLessThan(
221
+ html.indexOf("Let's talk"),
222
+ );
223
+ });
224
+ });
225
+
226
+ describe("about — unchanged with zero Person pages (AC-4, C5)", () => {
227
+ it("renders byte-identical to a direct renderPage call", async () => {
228
+ const aboutSource = {
229
+ frontmatter: {
230
+ type: "AboutPage",
231
+ slug: "about",
232
+ title: "About",
233
+ description: "About us.",
234
+ },
235
+ body: "About us copy.",
236
+ };
237
+ const getMdxSource = jest.fn().mockResolvedValue(aboutSource);
238
+ const getMdxSlugs = jest.fn().mockResolvedValue([]);
239
+
240
+ const element = await AboutPage({
241
+ params: Promise.resolve({ locale: "en" }),
242
+ getMdxSource,
243
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double over a narrower DI surface than the real multi-type loader
244
+ getMdxSlugs: getMdxSlugs as any,
245
+ });
246
+ const { container } = render(element);
247
+
248
+ const direct = await pageRenderer.renderPage({
249
+ type: "AboutPage",
250
+ slug: "about",
251
+ locale: "en",
252
+ translationsNamespace: "about",
253
+ getMdxSource,
254
+ });
255
+ const { container: directContainer } = render(direct);
256
+
257
+ expect(container.innerHTML).toBe(directContainer.innerHTML);
258
+ expect(
259
+ container.querySelectorAll('[data-testid="team-member-card"]'),
260
+ ).toHaveLength(0);
261
+ });
262
+ });
263
+
264
+ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug> (AC-3)", () => {
265
+ // The route handlers construct a Web `Response`, which jsdom (this file's
266
+ // test environment, needed by the React-rendering describes above) does
267
+ // not implement. A minimal same-shape stand-in, scoped to this describe,
268
+ // is enough for the one method these tests call: `.text()`.
269
+ let originalResponse: unknown;
270
+
271
+ beforeAll(() => {
272
+ originalResponse = (globalThis as { Response?: unknown }).Response;
273
+ class ResponseShim {
274
+ private readonly _body: string;
275
+ constructor(body: string, _init?: unknown) {
276
+ this._body = body;
277
+ }
278
+ async text(): Promise<string> {
279
+ return this._body;
280
+ }
281
+ }
282
+ (globalThis as { Response: unknown }).Response = ResponseShim;
283
+ });
284
+
285
+ afterAll(() => {
286
+ (globalThis as { Response?: unknown }).Response = originalResponse;
287
+ });
288
+
289
+ function makeStubAdapter(): ContentAdapter {
290
+ // eslint-disable-next-line no-restricted-syntax -- jest.fn() stubs are not assignable to ContentAdapter's generic return types; partial stub is intentional, tracked in #1073
291
+ return {
292
+ listPages: jest.fn().mockImplementation(async (type: string) => {
293
+ if (type === "Person") {
294
+ return [
295
+ {
296
+ type: "Person",
297
+ slug: "sam-henry",
298
+ title: "Sam Henry",
299
+ description: "Co-founder and partner.",
300
+ },
301
+ ];
302
+ }
303
+ return [];
304
+ }),
305
+ listDetail: jest.fn().mockResolvedValue([]),
306
+ getPageBySlug: jest.fn().mockResolvedValue(null),
307
+ getDetailBySlug: jest.fn().mockResolvedValue(null),
308
+ searchPages: jest.fn().mockResolvedValue([]),
309
+ extractStructuredData: jest.fn().mockResolvedValue(null),
310
+ createPage: jest.fn(),
311
+ updatePage: jest.fn(),
312
+ deletePage: jest.fn(),
313
+ getPageVersion: jest.fn(),
314
+ } as unknown as ContentAdapter;
315
+ }
316
+
317
+ beforeEach(() => {
318
+ process.env.NEXT_PUBLIC_ENABLED_LOCALES = "en";
319
+ process.env.NEXT_PUBLIC_DEFAULT_LOCALE = "en";
320
+ process.env.NEXT_PUBLIC_BASE_URL = "https://site.example";
321
+ });
322
+
323
+ afterEach(() => {
324
+ delete process.env.NEXT_PUBLIC_ENABLED_LOCALES;
325
+ delete process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
326
+ delete process.env.NEXT_PUBLIC_BASE_URL;
327
+ jest.resetAllMocks();
328
+ });
329
+
330
+ it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
331
+ const stubAdapter = makeStubAdapter();
332
+ const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter });
333
+ const response = await GET();
334
+ const text = await response.text();
335
+ const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
336
+ (m) => m[1],
337
+ );
338
+ expect(locs).toContain("https://site.example/about/sam-henry");
339
+ expect(locs).not.toContain("https://site.example/sam-henry");
340
+ });
341
+
342
+ it("llms.txt: names a URL ending in /about/sam-henry", async () => {
343
+ const stubAdapter = makeStubAdapter();
344
+ const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter });
345
+ const response = await GET();
346
+ const text = await response.text();
347
+ expect(text).toContain("URL: https://site.example/about/sam-henry");
348
+ expect(text).not.toContain("URL: https://site.example/sam-henry\n");
349
+ });
350
+ });