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
@@ -1,181 +1,184 @@
1
- "use client";
1
+ "use client"; // Accept calls a Server Action then refreshes the router; Sign out is the frame's client hook
2
2
 
3
3
  /**
4
- * AgreementsPrompt — the account home's sign-up-agreement re-ask (#8438
5
- * D13, spec docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
4
+ * AgreementsInterstitial — the full-page sign-up-agreement ask (#8765 D7,
5
+ * spec docs/specs/8765-sign-up-refuses-without-the-spec.md; replaces the
6
+ * Inbox's `AgreementsPrompt` card, D8).
6
7
  *
7
- * Renders only when `missing` is non-empty — `A/page.tsx` computes that
8
- * from `GET /v1/member/consents`: a `signUp` agreement with no OWN grant at
9
- * its current `policyVersion` (D4 — never an any-source row an anonymous
10
- * form attached, SEC-8438-1).
8
+ * The account layout renders this IN PLACE of `AccountFrame` and its
9
+ * children whenever `GET /v1/consent/account` answers `complete: false`, or
10
+ * fails closed on any read but a 2xx or an older hub's 404 (SEC-8765-C), so no
11
+ * account page, rail, Inbox or For-you panel loads behind it. The file keeps
12
+ * its name because `create-ailk`'s `surfaces.ts` syncs it by path.
11
13
  *
12
- * On mount, tries the CARRIED case first: `acceptCarriedAgreementsAction`
13
- * (this directory's own `agreements-actions.ts`) consumes the sign-up
14
- * card's signed, email-bound cookie and writes each missing agreement with
15
- * `via: "sign_up"`, so a member who just ticked the boxes on `/sign-up` is
16
- * never asked twice. On success the page refreshes and this component is
17
- * gone from the next render (`missing` becomes empty); on failure — no
18
- * cookie, expired, wrong account, or it does not cover every missing id —
19
- * the explicit card below shows instead.
14
+ * - One `h1`: the "updated" title when any agreement shown has an accepted
15
+ * (older) version, else the "new" one — then the lead line.
16
+ * - Each statement through `ConsentStatement`, its `[Label](key)` tokens
17
+ * linked by `consentPoliciesFor(locale)` (D9) — the three destinations
18
+ * `/contact` links (#8761).
19
+ * - Accept posts every id through `acceptAccountAgreementsAction`, then
20
+ * refreshes, so the layout's next read loads the account; a failed write
21
+ * shows the error line and leaves the page as it is.
22
+ * - Sign out is always there (D1's "never blocked"): `useAccountSignOut`,
23
+ * the frame's own Log out.
20
24
  *
21
- * Dynamically imported, never a static import, mirroring the generated
22
- * seam's own idiom (`topology.generated.ts`'s `readSession` imports
23
- * `../auth` dynamically): `./agreements-actions` reads a session and posts
24
- * through `@working-theory/api-client`, the same database/sign-in-adjacent
25
- * surface the seam exists to keep lazy. The cookie it consumes is minted by
26
- * `(auth)/sign-up/sign-up.actions.ts` — a sign-in SEED a sign-in-less spoke
27
- * drops (`SPOKE_REMOVED_ROOTS`/`spokeExclusions`,
28
- * `packages/create-ailk/src/topology.ts` D4) — but this file never reaches
29
- * that module directly; the consume-side logic lives here instead,
30
- * precisely so this directory carries no cross-boundary import into a seed
31
- * a spoke prunes. A spoke member signs up on the hub (C6), so on a spoke
32
- * there is simply no carried cookie to find — the "Otherwise" card below
33
- * shows instead, exactly as it does for any other failed carried check.
34
- *
35
- * "Otherwise" card: the statement (plain text, as `AgreementList` reads it
36
- * — no markdown-link parsing here either; see that component's own note)
37
- * plus a link to its policy when the wire carries one, and one Accept
38
- * button that posts every missing id through `setConsentAction` with no
39
- * `via` (`account_settings`).
25
+ * The wrapper carries the account area's `data-ailk-shell` marker, so the
26
+ * page draws in the same app tokens as the account it stands in for.
40
27
  */
41
28
 
42
- import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
29
+ import { ConsentStatement } from "@working-theory/ui/blocks";
30
+ import {
31
+ Button,
32
+ Heading,
33
+ InlineMessage,
34
+ Text,
35
+ } from "@working-theory/ui/primitives";
43
36
  import { useRouter } from "next/navigation";
44
- import { useEffect, useRef, useState, type ReactElement } from "react";
37
+ import { useState, type ReactElement } from "react";
38
+
39
+ import { consentPoliciesFor } from "../../../../lib/consent-policies";
45
40
 
46
- import { setConsentAction } from "./privacy/actions";
41
+ import { useAccountSignOut } from "./account-frame";
42
+ import { acceptAccountAgreementsAction } from "./agreements-actions";
47
43
 
48
- export interface MissingAgreement {
44
+ /** One sign-up agreement the person has not accepted at its current version. */
45
+ export interface AgreementsInterstitialAgreement {
49
46
  id: string;
47
+ /** The resolved statement, `[Label](key)` tokens included. */
50
48
  statement: string;
51
- policyHref: string | null;
52
- version: number;
53
- /** True when this purpose has an OWN grant at an older version (D13's "updated" copy). */
54
- hadPriorGrant: boolean;
49
+ /** The version last accepted, or `null` when never accepted. */
50
+ acceptedVersion: number | null;
55
51
  }
56
52
 
57
- export interface AgreementsPromptLabels {
53
+ export interface AgreementsInterstitialLabels {
58
54
  titleNew: string;
59
55
  titleUpdated: string;
56
+ lead: string;
60
57
  accept: string;
58
+ signOut: string;
61
59
  error: string;
62
- /** The policy link's label (the same text Privacy's own policy link uses). */
63
- policyLink: string;
60
+ /** Shown when a hub sign-out is refused or fails (#8465). */
61
+ signOutFailed: string;
64
62
  }
65
63
 
66
- export interface AgreementsPromptProps {
67
- missing: MissingAgreement[];
68
- labels: AgreementsPromptLabels;
64
+ export interface AgreementsInterstitialProps {
65
+ locale: string;
66
+ agreements: AgreementsInterstitialAgreement[];
67
+ labels: AgreementsInterstitialLabels;
68
+ /** The seam's `SIGN_OUT_ENDPOINT`, as the frame takes it (#8465). */
69
+ signOutEndpoint?: string | null;
70
+ /** Where a hub sign-out lands; defaults to the site home. */
71
+ signInHref?: string;
69
72
  }
70
73
 
71
- type CarriedCheck = "checking" | "hidden" | "show";
72
-
73
- export function AgreementsPrompt({ missing, labels }: AgreementsPromptProps): ReactElement | null {
74
+ export function AgreementsInterstitial({
75
+ locale,
76
+ agreements,
77
+ labels,
78
+ signOutEndpoint,
79
+ signInHref,
80
+ }: AgreementsInterstitialProps): ReactElement {
74
81
  const router = useRouter();
75
- const [state, setState] = useState<CarriedCheck>(missing.length === 0 ? "hidden" : "checking");
82
+ const { onSignOut, signOutFailed } = useAccountSignOut({
83
+ locale,
84
+ signOutEndpoint,
85
+ signInHref,
86
+ });
76
87
  const [pending, setPending] = useState(false);
77
- const [error, setError] = useState<string | null>(null);
78
- const attempted = useRef(false);
88
+ const [error, setError] = useState(false);
89
+ const policies = consentPoliciesFor(locale);
79
90
 
80
- useEffect(() => {
81
- if (missing.length === 0) {
82
- setState("hidden");
83
- return;
84
- }
85
- if (attempted.current) return;
86
- attempted.current = true;
87
- let active = true;
88
- void (async () => {
89
- try {
90
- const { acceptCarriedAgreementsAction } = await import("./agreements-actions");
91
- const result = await acceptCarriedAgreementsAction(missing.map((m) => m.id));
92
- if (!active) return;
93
- if (result.accepted) {
94
- setState("hidden");
95
- router.refresh();
96
- } else {
97
- setState("show");
98
- }
99
- } catch {
100
- if (active) setState("show");
101
- }
102
- })();
103
- return () => {
104
- active = false;
105
- };
106
-
107
- }, [router]);
91
+ const title = agreements.some(
92
+ (agreement) => agreement.acceptedVersion !== null,
93
+ )
94
+ ? labels.titleUpdated
95
+ : labels.titleNew;
108
96
 
109
- async function acceptAll(): Promise<void> {
97
+ async function accept(): Promise<void> {
110
98
  setPending(true);
111
- setError(null);
99
+ setError(false);
112
100
  try {
113
- const results = await Promise.all(missing.map((m) => setConsentAction(m.id, true)));
114
- if (results.every((r) => r.ok)) {
115
- // Mirrors the carried-cookie path: hide now, rather than leaving
116
- // `state` at "show" across the refresh below — the mount-time
117
- // effect that would otherwise notice `missing` is now empty never
118
- // re-runs (its deps are `[router]`, not `missing`), since this is
119
- // the SAME component instance, not a fresh mount.
120
- setState("hidden");
101
+ const result = await acceptAccountAgreementsAction(
102
+ agreements.map((agreement) => agreement.id),
103
+ );
104
+ if (result.ok) {
121
105
  router.refresh();
122
- } else {
123
- setError(labels.error);
124
- setPending(false);
106
+ return;
125
107
  }
126
108
  } catch {
127
- setError(labels.error);
128
- setPending(false);
109
+ // Falls through to the error line below.
129
110
  }
111
+ setError(true);
112
+ setPending(false);
130
113
  }
131
114
 
132
- if (state !== "show") return null;
133
-
134
- const title = missing.some((m) => m.hadPriorGrant) ? labels.titleUpdated : labels.titleNew;
135
-
136
115
  return (
137
- <div
138
- className="mb-block-loose flex flex-col gap-3 rounded-control border-hairline border-(--color-border) bg-surface-secondary p-4"
139
- data-testid="account-agreements-prompt"
140
- >
141
- <Heading level={2} visual={4}>
142
- {title}
143
- </Heading>
144
- <div className="flex flex-col gap-2">
145
- {missing.map((agreement) => (
146
- <div key={agreement.id} data-testid={`account-agreements-prompt-${agreement.id}`}>
147
- <Text as="p" variant="body-sm">
148
- {agreement.statement}
116
+ <div className="contents" data-ailk-shell="account">
117
+ <main
118
+ id="main"
119
+ className="flex min-h-screen items-center justify-center bg-bg px-4 py-section text-fg"
120
+ data-testid="account-agreements-interstitial"
121
+ >
122
+ <div className="flex w-full max-w-measure flex-col gap-6">
123
+ <div className="flex flex-col gap-2">
124
+ <Heading level={1} visual={2}>
125
+ {title}
126
+ </Heading>
127
+ <Text as="p" tone="muted">
128
+ {labels.lead}
149
129
  </Text>
150
- {agreement.policyHref ? (
151
- <Link
152
- href={agreement.policyHref}
153
- className="text-body-sm"
154
- data-testid={`account-agreements-prompt-${agreement.id}-policy`}
130
+ </div>
131
+
132
+ <div className="flex flex-col gap-3">
133
+ {agreements.map((agreement) => (
134
+ <span
135
+ key={agreement.id}
136
+ data-testid={`account-agreements-statement-${agreement.id}`}
155
137
  >
156
- {labels.policyLink}
157
- </Link>
158
- ) : null}
138
+ <ConsentStatement
139
+ statement={agreement.statement}
140
+ policies={policies}
141
+ />
142
+ </span>
143
+ ))}
144
+ </div>
145
+
146
+ {error ? (
147
+ <InlineMessage
148
+ variant="error"
149
+ data-testid="account-agreements-error"
150
+ >
151
+ {labels.error}
152
+ </InlineMessage>
153
+ ) : null}
154
+ {signOutFailed ? (
155
+ <InlineMessage variant="error" data-testid="account-sign-out-error">
156
+ {labels.signOutFailed}
157
+ </InlineMessage>
158
+ ) : null}
159
+
160
+ <div className="flex flex-col gap-3 md:flex-row">
161
+ <Button
162
+ type="button"
163
+ size="big"
164
+ disabled={pending}
165
+ onClick={() => void accept()}
166
+ data-testid="account-agreements-accept"
167
+ >
168
+ {labels.accept}
169
+ </Button>
170
+ <Button
171
+ type="button"
172
+ variant="ghost"
173
+ size="big"
174
+ onClick={onSignOut}
175
+ data-testid="account-agreements-sign-out"
176
+ >
177
+ {labels.signOut}
178
+ </Button>
159
179
  </div>
160
- ))}
161
- </div>
162
- {error ? (
163
- <span role="alert" data-testid="account-agreements-prompt-error">
164
- <Text as="span" variant="caption">
165
- {error}
166
- </Text>
167
- </span>
168
- ) : null}
169
- <div>
170
- <Button
171
- type="button"
172
- disabled={pending}
173
- onClick={() => void acceptAll()}
174
- data-testid="account-agreements-prompt-accept"
175
- >
176
- {labels.accept}
177
- </Button>
178
- </div>
180
+ </div>
181
+ </main>
179
182
  </div>
180
183
  );
181
184
  }
@@ -24,6 +24,16 @@
24
24
  * The `(authed)` layout above owns the signed-out gate, so a missing session
25
25
  * here is a race (it expired between the two layouts): the page renders
26
26
  * unframed and degrades on its own.
27
+ *
28
+ * #8765 D7 — after the session check the layout reads the caller's
29
+ * account-level sign-up agreements (`GET /v1/consent/account`). While any
30
+ * is not current, it returns `AgreementsInterstitial` IN PLACE of the frame
31
+ * and its children, so no account page loads behind it. It fails closed
32
+ * (SEC-8765-C): any answer but a 2xx or a 404 — a thrown read, a 401, 403,
33
+ * 429 or 5xx — shows the interstitial from the web's own copy of the
34
+ * registry, with the "new" title, and logs `read_failed`. Only a 404, an
35
+ * older hub with no such route, renders the account as before. The gate is
36
+ * on the account area's pages, never the member API (SEC-8765-L).
27
37
  */
28
38
 
29
39
  import { localeHref } from "@working-theory/ui";
@@ -35,6 +45,8 @@ import { SIGN_IN_PATH, SIGN_OUT_ENDPOINT } from "../../../../lib/topology.genera
35
45
 
36
46
  import { accountSession, loadForYou } from "./account-data";
37
47
  import { AccountFrame } from "./account-frame";
48
+ import { outstandingAgreements } from "./agreements-gate";
49
+ import { AgreementsInterstitial } from "./agreements-prompt";
38
50
  import { forYouLabels } from "./for-you-labels";
39
51
 
40
52
  /** The value when it parses as an https URL, else undefined: operator config is rendered as an href. */
@@ -61,6 +73,33 @@ export default async function AccountLayout({
61
73
  if (!session) return <>{children}</>;
62
74
 
63
75
  const t = await getTranslations({ locale, namespace: "account" });
76
+ const signOutProps = SIGN_OUT_ENDPOINT
77
+ ? {
78
+ signOutEndpoint: SIGN_OUT_ENDPOINT,
79
+ signInHref: localeHref(locale, SIGN_IN_PATH ?? "/"),
80
+ }
81
+ : {};
82
+
83
+ const outstanding = await outstandingAgreements(session.ctx);
84
+ if (outstanding.length > 0) {
85
+ return (
86
+ <AgreementsInterstitial
87
+ locale={locale}
88
+ agreements={outstanding}
89
+ {...signOutProps}
90
+ labels={{
91
+ titleNew: t("agreements.titleNew"),
92
+ titleUpdated: t("agreements.titleUpdated"),
93
+ lead: t("agreements.lead"),
94
+ accept: t("agreements.accept"),
95
+ signOut: t("agreements.signOut"),
96
+ error: t("agreements.error"),
97
+ signOutFailed: t("frame.signOutFailed"),
98
+ }}
99
+ />
100
+ );
101
+ }
102
+
64
103
  const forYou = await loadForYou(locale);
65
104
  const { name, email } = session.ctx.user;
66
105
  const helpCenterUrl = httpsUrl(process.env.AILK_HELP_CENTER_URL);
@@ -81,12 +120,7 @@ export default async function AccountLayout({
81
120
  : []),
82
121
  ]}
83
122
  forYou={{ items: forYou.items, now: forYou.now, labels: forYouLabels(session.ctx.t) }}
84
- {...(SIGN_OUT_ENDPOINT
85
- ? {
86
- signOutEndpoint: SIGN_OUT_ENDPOINT,
87
- signInHref: localeHref(locale, SIGN_IN_PATH ?? "/"),
88
- }
89
- : {})}
123
+ {...signOutProps}
90
124
  labels={{
91
125
  home: t("frame.home"),
92
126
  inbox: t("frame.inbox"),
@@ -39,9 +39,12 @@
39
39
  * whose `available` read resolved `true`, each opening its popup over the
40
40
  * Inbox. None offered (a Free build) → no New at all. A read that rejected
41
41
  * drops its row and shows the error row too — a failed read is never silent.
42
+ *
43
+ * #8765 D8 — the Inbox renders nothing about agreements: an outstanding
44
+ * sign-up agreement is asked by the layout's full-page interstitial, before
45
+ * this page loads at all.
42
46
  */
43
47
 
44
- import { member } from "@working-theory/api-client";
45
48
  import {
46
49
  ForYouPanel,
47
50
  InboxList,
@@ -50,9 +53,17 @@ import {
50
53
  type InboxListRow,
51
54
  } from "@working-theory/templates/account";
52
55
  import { Heading, Text } from "@working-theory/ui/primitives";
53
- import { CalendarClock, ClipboardList, Receipt, type LucideProps } from "lucide-react";
56
+ import {
57
+ CalendarClock,
58
+ ClipboardList,
59
+ Receipt,
60
+ type LucideProps,
61
+ } from "lucide-react";
54
62
  import type { Metadata } from "next";
63
+ import { headers } from "next/headers";
55
64
  import { getTranslations } from "next-intl/server";
65
+ import { SITE_NAME } from "../../../../lib/site-brand";
66
+ import { readSession } from "../../../../lib/topology.generated";
56
67
  import type { ComponentType, ReactElement } from "react";
57
68
 
58
69
  import {
@@ -63,7 +74,7 @@ import {
63
74
  logReadFailure,
64
75
  type LoadedInboxItem,
65
76
  } from "./account-data";
66
- import { AgreementsPrompt, type MissingAgreement } from "./agreements-prompt";
77
+ import { outstandingAgreements } from "./agreements-gate";
67
78
  import { ErrorRow } from "./error-row";
68
79
  import { ACCOUNT_PERSIST_KEY, forYouLabels } from "./for-you-labels";
69
80
  import { NewMenu, type NewMenuAction } from "./new-menu";
@@ -76,6 +87,18 @@ interface Props {
76
87
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
77
88
  const { locale } = await params;
78
89
  const t = await getTranslations({ locale, namespace: "account" });
90
+ // Signed out, the `(authed)` layout renders the sign-in gate in this page's
91
+ // place, so the title names THAT state — "Sign in · <site>" — not the Inbox
92
+ // the visitor cannot see yet (#8790 AC-1). The session read is the layout's
93
+ // own (`topology.generated`'s `readSession`); any failure keeps the Inbox
94
+ // title, exactly as before.
95
+ const session = await readSession(await headers()).catch(() => null);
96
+ if (!session) {
97
+ return {
98
+ title: `${t("gate.title")} · ${SITE_NAME}`,
99
+ description: t("description"),
100
+ };
101
+ }
79
102
  return { title: t("inbox.title"), description: t("description") };
80
103
  }
81
104
 
@@ -97,69 +120,57 @@ function soonestFirst(a: LoadedInboxItem, b: LoadedInboxItem): number {
97
120
  async function loadNewActions(
98
121
  ctx: AccountContext,
99
122
  ): Promise<{ actions: NewMenuAction[]; failed: boolean }> {
100
- const settled = await Promise.allSettled(newActions.map((action) => action.available(ctx)));
123
+ const settled = await Promise.allSettled(
124
+ newActions.map((action) => action.available(ctx)),
125
+ );
101
126
  const actions: NewMenuAction[] = [];
102
127
  settled.forEach((result, i) => {
103
128
  const action = newActions[i]!;
104
- if (result.status === "rejected") logReadFailure(`new:${action.id}`, result.reason);
129
+ if (result.status === "rejected")
130
+ logReadFailure(`new:${action.id}`, result.reason);
105
131
  else if (result.value)
106
- actions.push({ id: action.id, label: ctx.t(action.label), popup: action.renderPopup(ctx) });
132
+ actions.push({
133
+ id: action.id,
134
+ label: ctx.t(action.label),
135
+ popup: action.renderPopup(ctx),
136
+ });
107
137
  });
108
- return { actions, failed: settled.some((result) => result.status === "rejected") };
138
+ return {
139
+ actions,
140
+ failed: settled.some((result) => result.status === "rejected"),
141
+ };
109
142
  }
110
143
 
111
144
  /** Upcoming items first, soonest first; then the rest, as loaded (newest first). */
112
145
  function scheduledOrder(rows: readonly LoadedInboxItem[]): LoadedInboxItem[] {
113
- const upcoming = rows.filter(({ item }) => item.upcoming === true).sort(soonestFirst);
146
+ const upcoming = rows
147
+ .filter(({ item }) => item.upcoming === true)
148
+ .sort(soonestFirst);
114
149
  return [...upcoming, ...rows.filter(({ item }) => item.upcoming !== true)];
115
150
  }
116
151
 
117
- /**
118
- * #8438 D13 — the `signUp` agreements this caller's OWN state does not
119
- * cover at their CURRENT registry version. A failed or absent read answers
120
- * none: the prompt is a convenience, never a page-breaking requirement.
121
- */
122
- async function loadMissingAgreements(ctx: AccountContext, locale: string): Promise<MissingAgreement[]> {
123
- try {
124
- const res = await member.consents(ctx.init, { locale });
125
- if (!res.ok) return [];
126
- return res.body.purposes
127
- .filter((purpose) => purpose.signUp)
128
- .filter(
129
- (purpose) =>
130
- !purpose.state || !purpose.state.granted || purpose.state.policyVersion !== purpose.policyVersion,
131
- )
132
- .map((purpose) => ({
133
- id: purpose.id,
134
- statement: purpose.statement,
135
- policyHref: purpose.policyHref,
136
- version: purpose.policyVersion,
137
- hadPriorGrant: purpose.state !== null,
138
- }));
139
- } catch {
140
- return [];
141
- }
142
- }
143
-
144
- export default async function AccountInboxPage({ params }: Props): Promise<ReactElement> {
152
+ export default async function AccountInboxPage({ params }: Props): Promise<ReactElement | null> {
145
153
  const { locale } = await params;
146
154
  const t = await getTranslations({ locale, namespace: "account" });
147
155
  const session = await accountSession(locale);
148
156
 
157
+ // The layout shows the interstitial in place of this page while an
158
+ // agreement is outstanding, but the App Router still renders the page: no
159
+ // Inbox loader may run meanwhile (`member.messages` stamps `readAt`, #8765 D7).
160
+ if (session && (await outstandingAgreements(session.ctx)).length > 0) return null;
161
+
149
162
  // The contract's translator, for keys that are data (a kind's `label`).
150
163
  const translate = session?.ctx.t ?? ((key: string): string => key);
151
- const [inbox, forYou, newMenu, missingAgreements] = session
164
+ const [inbox, forYou, newMenu] = session
152
165
  ? await Promise.all([
153
166
  loadInboxItems(session.ctx),
154
167
  loadForYou(locale),
155
168
  loadNewActions(session.ctx),
156
- loadMissingAgreements(session.ctx, locale),
157
169
  ])
158
170
  : [
159
171
  { items: [], failed: false },
160
172
  { items: [], failed: false, now: "" },
161
173
  { actions: [], failed: false },
162
- [],
163
174
  ];
164
175
 
165
176
  const dateFormat = new Intl.DateTimeFormat(locale, {
@@ -173,14 +184,18 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
173
184
  return {
174
185
  id: `${kind.kind}:${item.id}`,
175
186
  ...(icon ? { icon } : {}),
176
- caption: item.at ? `${label} · ${dateFormat.format(new Date(item.at))}` : label,
187
+ caption: item.at
188
+ ? `${label} · ${dateFormat.format(new Date(item.at))}`
189
+ : label,
177
190
  title: item.title,
178
191
  href: inboxItemHref(locale, kind.kind, item.id),
179
192
  };
180
193
  };
181
194
 
182
195
  const all = inbox.items.map(toRow);
183
- const purchases = inbox.items.filter(({ kind }) => kind.section === "purchases").map(toRow);
196
+ const purchases = inbox.items
197
+ .filter(({ kind }) => kind.section === "purchases")
198
+ .map(toRow);
184
199
  const scheduled = scheduledOrder(
185
200
  inbox.items.filter(({ kind }) => kind.section === "upcoming"),
186
201
  ).map(toRow);
@@ -189,17 +204,6 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
189
204
  return (
190
205
  // The phone's top bar already clears the canvas — no top inset there.
191
206
  <div className="max-w-3xl py-8 max-md:pt-0" data-testid="account-inbox">
192
- {/* #8438 D13 — the sign-up agreement re-ask, above both phone tabs. */}
193
- <AgreementsPrompt
194
- missing={missingAgreements}
195
- labels={{
196
- titleNew: t("agreements.titleNew"),
197
- titleUpdated: t("agreements.titleUpdated"),
198
- accept: t("agreements.accept"),
199
- error: t("agreements.error"),
200
- policyLink: t("settings.privacy.policyLink"),
201
- }}
202
- />
203
207
  {/* The page's one h1, on both phone tabs; visually hidden on the phone,
204
208
  which has no large title (§5.15). New sits beside it (#8233 D4). */}
205
209
  <div className="flex items-center justify-between gap-2">
@@ -212,7 +216,12 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
212
216
  />
213
217
  </div>
214
218
  <PhoneTabPanel tabs={["inbox"]} desktop data-testid="inbox-panel-inbox">
215
- <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight hidden md:block">
219
+ <Text
220
+ as="p"
221
+ variant="body-sm"
222
+ tone="muted"
223
+ className="mt-block-tight hidden md:block"
224
+ >
216
225
  {t("inbox.lead")}
217
226
  </Text>
218
227
 
@@ -226,7 +235,12 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
226
235
 
227
236
  <InboxList
228
237
  tabs={[
229
- { id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
238
+ {
239
+ id: "all",
240
+ label: t("inbox.tabs.all"),
241
+ count: all.length,
242
+ rows: all,
243
+ },
230
244
  {
231
245
  id: "purchases",
232
246
  label: t("inbox.tabs.purchases"),
@@ -14,7 +14,9 @@
14
14
  * 3. account-privacy-consents — one `ConsentSwitchList` row per
15
15
  * `area: "data"` purpose, or the "no other data-use consents" line.
16
16
  * 4. account-privacy-agreements — a read-only `AgreementList` of every
17
- * agreement (`service_terms`, `account_terms`, …), or "None yet".
17
+ * agreement (`service_terms`, `account_terms`, …), or "None yet"; each
18
+ * statement's links resolve through `consentPoliciesFor(locale)`
19
+ * (#8765 D11).
18
20
  * 5. account-deletion — moved unchanged from General (#8438 D16).
19
21
  *
20
22
  * Sections 3 and 4 read `GET /v1/member/consents`; a failed read shows one
@@ -35,6 +37,7 @@ import { cookies } from "next/headers";
35
37
  import { getTranslations } from "next-intl/server";
36
38
  import type { ReactElement } from "react";
37
39
 
40
+ import { consentPoliciesFor } from "../../../../../lib/consent-policies";
38
41
  import { accountSession } from "../account-data";
39
42
  import { DeletionRequest } from "../general/deletion-request";
40
43
  import { loadProfileSource } from "../general/profile-source";
@@ -188,6 +191,7 @@ export default async function AccountPrivacyPage({ params }: Props): Promise<Rea
188
191
  notAcceptedLine={t("settings.privacy.agreementsEmpty")}
189
192
  emptyLine={t("settings.privacy.agreementsEmpty")}
190
193
  testIdPrefix="account-privacy-agreement"
194
+ policies={consentPoliciesFor(locale)}
191
195
  />
192
196
  </div>
193
197
  </>