create-ailk 0.19.1 → 0.20.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 (132) hide show
  1. package/README.md +64 -7
  2. package/dist/cli.js +35 -0
  3. package/dist/copy.d.ts +41 -0
  4. package/dist/copy.js +87 -21
  5. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  6. package/dist/lib/fetch-template-snapshot.js +205 -0
  7. package/dist/lib/merge-three-way.d.ts +31 -0
  8. package/dist/lib/merge-three-way.js +65 -0
  9. package/dist/module-architecture.js +55 -2
  10. package/dist/programmatic.d.ts +7 -1
  11. package/dist/programmatic.js +32 -2
  12. package/dist/spoke-checkout.d.ts +7 -0
  13. package/dist/spoke-checkout.js +8 -1
  14. package/dist/surfaces.js +67 -2
  15. package/dist/template-record.d.ts +59 -0
  16. package/dist/template-record.js +183 -0
  17. package/dist/update.d.ts +48 -0
  18. package/dist/update.js +504 -0
  19. package/package.json +3 -3
  20. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  21. package/templates/.claude/hooks/architecture-check.sh +8 -0
  22. package/templates/.claude/rules/loop-contract.md +129 -0
  23. package/templates/.claude/settings.json +3 -2
  24. package/templates/.env.example +13 -0
  25. package/templates/CLAUDE.md +1 -1
  26. package/templates/CONVENTIONS.md +3 -2
  27. package/templates/_gitignore +162 -0
  28. package/templates/apps/api/CLAUDE.md +14 -10
  29. package/templates/apps/api/package.json +2 -1
  30. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  31. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  32. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  33. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  34. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  35. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  36. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  37. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  38. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  39. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  40. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  41. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  42. package/templates/apps/api/src/middleware/auth.ts +8 -4
  43. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  44. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  45. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  46. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  47. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  48. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  49. package/templates/apps/api/src/server.ts +14 -2
  50. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  51. package/templates/apps/api/src/services/audit-log.ts +72 -10
  52. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  53. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  54. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  55. package/templates/apps/api/vercel.json +1 -1
  56. package/templates/apps/mcp/CLAUDE.md +19 -7
  57. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  58. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  59. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  60. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  61. package/templates/apps/web/_gitignore +6 -0
  62. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  63. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  64. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  65. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  75. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  79. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  87. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  88. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  89. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  96. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  97. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  98. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  101. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  102. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  103. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  104. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  105. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  106. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  107. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  108. package/templates/apps/web/jest.config.cjs +3 -0
  109. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  110. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  111. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  112. package/templates/apps/web/lib/account-config.ts +31 -16
  113. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  114. package/templates/apps/web/lib/relay.ts +20 -0
  115. package/templates/apps/web/messages/en.json +67 -4
  116. package/templates/apps/web/next.config.mjs +8 -2
  117. package/templates/database/CHANGELOG.md +39 -0
  118. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  119. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  120. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  121. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  122. package/templates/database/_gitignore +8 -0
  123. package/templates/database/inbox/schema.prisma +38 -12
  124. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  125. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  126. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  127. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  128. package/templates/database/ops/schema.prisma +16 -0
  129. package/templates/database/package.json +1 -1
  130. package/templates/project.yaml +6 -3
  131. package/versions.json +35 -35
  132. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,322 @@
1
+ "use client"; // owns the answers' state and posts them through useActionState
2
+
3
+ /**
4
+ * OnboardingForm — the client's onboarding questions (#8515, spec
5
+ * docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md D12).
6
+ *
7
+ * Renders every served step in order — the server checks every answer and
8
+ * evaluates no `showIf` (D9). A field step renders each field as `Input`
9
+ * (text, email, tel, url), `Textarea` or `Select`; a segmentation step renders
10
+ * each question as a `RadioGroup` (single choice) or one `Checkbox` per option
11
+ * (`multi`), labelled by the step's title. Copy is the provider's
12
+ * `LocalizedString`s, resolved for the form's `defaultLocale` (else its first
13
+ * locale) and rendered as text, never markup.
14
+ *
15
+ * Every control is named `answer.<name>` — a multi question's checkboxes
16
+ * repeat it. The form is controlled and posts what its state holds, so a
17
+ * refusal keeps every answer: one `answer.<name>` entry per value, plus a
18
+ * `multi` entry naming each multi question, so `submitOnboarding` sends a
19
+ * multi answer as an array even when one box is checked. A link is sent as
20
+ * typed (`inputMode="url"`, no browser URL check): the server's https rule
21
+ * is the only one. On success a status line replaces the form.
22
+ */
23
+
24
+ import {
25
+ Button,
26
+ Checkbox,
27
+ Heading,
28
+ InlineMessage,
29
+ Input,
30
+ Label,
31
+ RadioGroup,
32
+ RadioGroupItem,
33
+ Select,
34
+ SelectContent,
35
+ SelectItem,
36
+ SelectTrigger,
37
+ SelectValue,
38
+ Text,
39
+ Textarea,
40
+ } from "@working-theory/ui/primitives";
41
+ import { resolveLocalizedString, type MemberOnboardingStep } from "@working-theory/validation";
42
+ import {
43
+ startTransition,
44
+ useActionState,
45
+ useId,
46
+ useState,
47
+ type FormEvent,
48
+ type ReactElement,
49
+ } from "react";
50
+
51
+ import {
52
+ submitOnboarding,
53
+ type OnboardingErrorCode,
54
+ type OnboardingForm as OnboardingFormData,
55
+ type OnboardingState,
56
+ } from "./actions";
57
+
58
+ type FieldStep = Extract<MemberOnboardingStep, { type: "field" }>;
59
+ type SegmentationStep = Extract<MemberOnboardingStep, { type: "segmentation" }>;
60
+ type FormField = FieldStep["fields"][number];
61
+ type Question = SegmentationStep["questions"][number];
62
+
63
+ export interface OnboardingFormLabels {
64
+ optional: string;
65
+ submit: string;
66
+ submitting: string;
67
+ sent: string;
68
+ errors: Record<OnboardingErrorCode, string>;
69
+ }
70
+
71
+ export interface OnboardingFormProps {
72
+ form: OnboardingFormData;
73
+ labels: OnboardingFormLabels;
74
+ }
75
+
76
+ export function OnboardingForm({ form, labels }: OnboardingFormProps): ReactElement {
77
+ const id = useId();
78
+ const [answers, setAnswers] = useState<Record<string, string | string[]>>({});
79
+ const [state, dispatch, pending] = useActionState<OnboardingState, FormData>(
80
+ submitOnboarding,
81
+ { kind: "idle" },
82
+ );
83
+
84
+ if (state.kind === "sent") {
85
+ return (
86
+ <InlineMessage
87
+ variant="success"
88
+ role="status"
89
+ className="text-body-sm"
90
+ data-testid="account-onboarding-sent"
91
+ >
92
+ {labels.sent}
93
+ </InlineMessage>
94
+ );
95
+ }
96
+
97
+ const text = (value: Record<string, string>): string =>
98
+ resolveLocalizedString(value, form.defaultLocale, form.defaultLocale);
99
+ const setAnswer = (name: string, value: string | string[]): void =>
100
+ setAnswers((current) => ({ ...current, [name]: value }));
101
+ const single = (name: string): string => {
102
+ const value = answers[name];
103
+ return typeof value === "string" ? value : "";
104
+ };
105
+ const picked = (name: string): string[] => {
106
+ const value = answers[name];
107
+ return Array.isArray(value) ? value : [];
108
+ };
109
+
110
+ const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
111
+ event.preventDefault();
112
+ const formData = new FormData();
113
+ for (const step of form.steps) {
114
+ if (step.type !== "segmentation") continue;
115
+ for (const question of step.questions) {
116
+ if (question.multi) formData.append("multi", question.name);
117
+ }
118
+ }
119
+ for (const [name, value] of Object.entries(answers)) {
120
+ for (const one of Array.isArray(value) ? value : [value]) {
121
+ formData.append(`answer.${name}`, one);
122
+ }
123
+ }
124
+ startTransition(() => dispatch(formData));
125
+ };
126
+
127
+ const renderField = (field: FormField): ReactElement => {
128
+ const controlId = `${id}-${field.name}`;
129
+ const helpId = `${controlId}-help`;
130
+ const help = field.copy.help ? text(field.copy.help) : null;
131
+ const placeholder = field.copy.placeholder ? text(field.copy.placeholder) : undefined;
132
+ const value = single(field.name);
133
+ const common = {
134
+ id: controlId,
135
+ name: `answer.${field.name}`,
136
+ required: field.required === true,
137
+ ...(help ? { "aria-describedby": helpId } : {}),
138
+ "data-testid": `account-onboarding-field-${field.name}`,
139
+ };
140
+ const onChange = (event: { target: { value: string } }): void =>
141
+ setAnswer(field.name, event.target.value);
142
+
143
+ let control: ReactElement;
144
+ if (field.inputType === "textarea") {
145
+ control = (
146
+ <Textarea {...common} rows={4} value={value} placeholder={placeholder} onChange={onChange} />
147
+ );
148
+ } else if (field.inputType === "select") {
149
+ control = (
150
+ <Select
151
+ name={common.name}
152
+ required={common.required}
153
+ value={value}
154
+ onValueChange={(next) => setAnswer(field.name, next)}
155
+ >
156
+ <SelectTrigger
157
+ id={controlId}
158
+ {...(help ? { "aria-describedby": helpId } : {})}
159
+ data-testid={common["data-testid"]}
160
+ >
161
+ <SelectValue {...(placeholder ? { placeholder } : {})} />
162
+ </SelectTrigger>
163
+ <SelectContent>
164
+ {(field.options ?? []).map((option) => (
165
+ <SelectItem key={option.value} value={option.value}>
166
+ {text(option.label)}
167
+ </SelectItem>
168
+ ))}
169
+ </SelectContent>
170
+ </Select>
171
+ );
172
+ } else {
173
+ control = (
174
+ <Input
175
+ {...common}
176
+ // A url field takes text: no browser URL check, so the server's plain refusal is the rule.
177
+ type={field.inputType === "url" ? "text" : field.inputType}
178
+ {...(field.inputType === "url" ? { inputMode: "url" as const } : {})}
179
+ value={value}
180
+ placeholder={placeholder}
181
+ onChange={onChange}
182
+ />
183
+ );
184
+ }
185
+
186
+ return (
187
+ <div key={field.name} className="flex flex-col gap-2">
188
+ <div className="flex items-baseline gap-2">
189
+ <Label htmlFor={controlId}>{text(field.copy.label)}</Label>
190
+ {field.required ? null : (
191
+ <Text as="span" variant="caption">
192
+ {labels.optional}
193
+ </Text>
194
+ )}
195
+ </div>
196
+ {control}
197
+ {help ? (
198
+ <Text as="p" id={helpId} variant="caption">
199
+ {help}
200
+ </Text>
201
+ ) : null}
202
+ </div>
203
+ );
204
+ };
205
+
206
+ /** One question, labelled by its step's title (`titleId`). */
207
+ const renderQuestion = (question: Question, titleId: string): ReactElement => {
208
+ const groupId = `${id}-${question.name}`;
209
+ const name = `answer.${question.name}`;
210
+ const optionId = (index: number): string => `${groupId}-${index}`;
211
+
212
+ if (!question.multi) {
213
+ return (
214
+ <RadioGroup
215
+ key={question.name}
216
+ aria-labelledby={titleId}
217
+ name={name}
218
+ required
219
+ value={single(question.name)}
220
+ onValueChange={(next) => setAnswer(question.name, next)}
221
+ data-testid={`account-onboarding-field-${question.name}`}
222
+ >
223
+ {question.options.map((option, index) => (
224
+ <div key={option.value} className="flex min-h-11 items-center gap-3">
225
+ <RadioGroupItem id={optionId(index)} value={option.value} />
226
+ <Label htmlFor={optionId(index)}>{text(option.label)}</Label>
227
+ </div>
228
+ ))}
229
+ </RadioGroup>
230
+ );
231
+ }
232
+
233
+ const exclusive = new Set(question.exclusiveValues ?? []);
234
+ const values = picked(question.name);
235
+ // A value in `exclusiveValues` stands alone (D9): checking it clears the
236
+ // others, and checking another clears it. Kept in the options' order.
237
+ const toggle = (value: string, checked: boolean): void => {
238
+ const next = new Set(
239
+ checked
240
+ ? exclusive.has(value)
241
+ ? [value]
242
+ : [...values.filter((v) => !exclusive.has(v)), value]
243
+ : values.filter((v) => v !== value),
244
+ );
245
+ setAnswer(
246
+ question.name,
247
+ question.options.map((o) => o.value).filter((v) => next.has(v)),
248
+ );
249
+ };
250
+ return (
251
+ <div
252
+ key={question.name}
253
+ role="group"
254
+ aria-labelledby={titleId}
255
+ className="flex flex-col"
256
+ data-testid={`account-onboarding-field-${question.name}`}
257
+ >
258
+ {question.options.map((option, index) => (
259
+ <div key={option.value} className="flex min-h-11 items-center gap-3">
260
+ <Checkbox
261
+ id={optionId(index)}
262
+ name={name}
263
+ value={option.value}
264
+ checked={values.includes(option.value)}
265
+ onCheckedChange={(checked) => toggle(option.value, checked === true)}
266
+ />
267
+ <Label htmlFor={optionId(index)}>{text(option.label)}</Label>
268
+ </div>
269
+ ))}
270
+ </div>
271
+ );
272
+ };
273
+
274
+ /** A segmentation step: its title once, then each question it asks. */
275
+ const renderSegmentation = (step: SegmentationStep): ReactElement => {
276
+ const titleId = `${id}-${step.key}-title`;
277
+ return (
278
+ <div key={step.key} className="flex flex-col gap-2">
279
+ <Text as="p" id={titleId} variant="body-sm" className="font-semibold">
280
+ {text(step.copy.title)}
281
+ </Text>
282
+ {step.questions.map((question) => renderQuestion(question, titleId))}
283
+ </div>
284
+ );
285
+ };
286
+
287
+ return (
288
+ <form onSubmit={onSubmit} className="flex flex-col gap-6" data-testid="account-onboarding-form">
289
+ {form.steps.map((step) =>
290
+ step.type === "segmentation" ? (
291
+ renderSegmentation(step)
292
+ ) : (
293
+ <div key={step.key} className="flex flex-col gap-4">
294
+ {/* A one-field step's title is its field's label; a heading only when it groups several. */}
295
+ {step.fields.length > 1 ? (
296
+ <Heading level={2} visual={4}>
297
+ {text(step.copy.title)}
298
+ </Heading>
299
+ ) : null}
300
+ {step.fields.map(renderField)}
301
+ </div>
302
+ ),
303
+ )}
304
+ {state.kind === "error" ? (
305
+ <InlineMessage variant="error" className="text-body-sm" data-testid="account-onboarding-error">
306
+ {labels.errors[state.code]}
307
+ </InlineMessage>
308
+ ) : null}
309
+ <div>
310
+ <Button
311
+ type="submit"
312
+ disabled={pending}
313
+ // 44px on a phone (a touch-first context); the 36px primary from md up.
314
+ className="max-md:min-h-11"
315
+ data-testid="account-onboarding-submit"
316
+ >
317
+ {pending ? labels.submitting : labels.submit}
318
+ </Button>
319
+ </div>
320
+ </form>
321
+ );
322
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * /[locale]/account/onboarding — the client's onboarding questions (#8515,
3
+ * spec docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md
4
+ * D12).
5
+ *
6
+ * RSC, inside the account layout's SignedInShell. Reached from the Open
7
+ * onboarding action on a message the business sent with Start onboarding
8
+ * (`lib/account-config.ts`, D6). It reads the workspace's standard
9
+ * onboarding magnet through `readOnboardingForm` (`GET /v1/member/onboarding`)
10
+ * and renders:
11
+ *
12
+ * - the form's title and `OnboardingForm`, which posts the answers through
13
+ * `submitOnboarding`;
14
+ * - `form: null` — onboarding not open to this caller here (switched off,
15
+ * not seeded, no Person, or no member area on this build) — a plain line
16
+ * and a link back to the account;
17
+ * - a failed read — the account area's error row with Retry (spec 7423
18
+ * D12), never an empty page.
19
+ *
20
+ * Nothing here tests the topology: the read sends `forwardedCookieInit()`'s
21
+ * init, so the same file serves a single site and a hub-mode spoke.
22
+ */
23
+
24
+ import { localeHref } from "@working-theory/ui";
25
+ import { Heading, Link, Text } from "@working-theory/ui/primitives";
26
+ import type { Metadata } from "next";
27
+ import { getTranslations } from "next-intl/server";
28
+ import type { ReactElement } from "react";
29
+
30
+ import { accountSession } from "../account-data";
31
+ import { ErrorRow } from "../error-row";
32
+
33
+ import { readOnboardingForm } from "./actions";
34
+ import { OnboardingForm } from "./onboarding-form";
35
+
36
+ interface Props {
37
+ params: Promise<{ locale: string }>;
38
+ }
39
+
40
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
41
+ const { locale } = await params;
42
+ const t = await getTranslations({ locale, namespace: "account" });
43
+ return { title: t("onboarding.title") };
44
+ }
45
+
46
+ export default async function AccountOnboardingPage({ params }: Props): Promise<ReactElement> {
47
+ const { locale } = await params;
48
+ const t = await getTranslations({ locale, namespace: "account" });
49
+ const session = await accountSession(locale);
50
+
51
+ // Unreachable behind the (authed) gate — the layout renders the sign-in
52
+ // gate instead — but the page still renders in parallel with it, so a
53
+ // `notFound()` here would stream a 404 digest over the gate (the scaffold
54
+ // render sweep's `streamed not-found`, as `../artifacts/page.tsx` records).
55
+ // With no session: the heading alone, and nothing is read.
56
+ if (!session) {
57
+ return (
58
+ <div className="max-w-3xl py-8" data-testid="account-onboarding">
59
+ <Heading level={1} visual={2}>
60
+ {t("onboarding.title")}
61
+ </Heading>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ const read = await readOnboardingForm();
67
+
68
+ if (read.kind === "error") {
69
+ return (
70
+ <div className="max-w-3xl py-8" data-testid="account-onboarding">
71
+ <Heading level={1} visual={2}>
72
+ {t("onboarding.title")}
73
+ </Heading>
74
+ <ErrorRow
75
+ className="mt-block-loose"
76
+ message={t("onboarding.errors.load")}
77
+ retryLabel={t("onboarding.retry")}
78
+ />
79
+ </div>
80
+ );
81
+ }
82
+
83
+ if (read.form === null) {
84
+ return (
85
+ <div className="max-w-3xl py-8" data-testid="account-onboarding">
86
+ <Heading level={1} visual={2}>
87
+ {t("onboarding.title")}
88
+ </Heading>
89
+ <div className="mt-block-tight" data-testid="account-onboarding-unavailable">
90
+ <Text as="p" variant="body-sm" tone="muted">
91
+ {t("onboarding.unavailable")}
92
+ </Text>
93
+ </div>
94
+ <Text as="p" variant="body-sm" className="mt-block-tight">
95
+ <Link href={localeHref(locale, "/account")} data-testid="account-onboarding-back">
96
+ {t("onboarding.back")}
97
+ </Link>
98
+ </Text>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="account-onboarding">
105
+ <Heading level={1} visual={2}>
106
+ {read.form.title}
107
+ </Heading>
108
+ <OnboardingForm
109
+ form={read.form}
110
+ labels={{
111
+ optional: t("onboarding.optional"),
112
+ submit: t("onboarding.submit"),
113
+ submitting: t("onboarding.submitting"),
114
+ sent: t("onboarding.sent"),
115
+ errors: {
116
+ onboarding_off: t("onboarding.errors.onboardingOff"),
117
+ answers_invalid: t("onboarding.errors.answersInvalid"),
118
+ link_not_https: t("onboarding.errors.linkNotHttps"),
119
+ too_many_requests: t("onboarding.errors.tooManyRequests"),
120
+ failed: t("onboarding.errors.failed"),
121
+ },
122
+ }}
123
+ />
124
+ </div>
125
+ );
126
+ }
@@ -41,6 +41,7 @@
41
41
  * drops its row and shows the error row too — a failed read is never silent.
42
42
  */
43
43
 
44
+ import { member } from "@working-theory/api-client";
44
45
  import {
45
46
  ForYouPanel,
46
47
  InboxList,
@@ -62,6 +63,7 @@ import {
62
63
  logReadFailure,
63
64
  type LoadedInboxItem,
64
65
  } from "./account-data";
66
+ import { AgreementsPrompt, type MissingAgreement } from "./agreements-prompt";
65
67
  import { ErrorRow } from "./error-row";
66
68
  import { ACCOUNT_PERSIST_KEY, forYouLabels } from "./for-you-labels";
67
69
  import { NewMenu, type NewMenuAction } from "./new-menu";
@@ -112,6 +114,33 @@ function scheduledOrder(rows: readonly LoadedInboxItem[]): LoadedInboxItem[] {
112
114
  return [...upcoming, ...rows.filter(({ item }) => item.upcoming !== true)];
113
115
  }
114
116
 
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
+
115
144
  export default async function AccountInboxPage({ params }: Props): Promise<ReactElement> {
116
145
  const { locale } = await params;
117
146
  const t = await getTranslations({ locale, namespace: "account" });
@@ -119,16 +148,18 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
119
148
 
120
149
  // The contract's translator, for keys that are data (a kind's `label`).
121
150
  const translate = session?.ctx.t ?? ((key: string): string => key);
122
- const [inbox, forYou, newMenu] = session
151
+ const [inbox, forYou, newMenu, missingAgreements] = session
123
152
  ? await Promise.all([
124
153
  loadInboxItems(session.ctx),
125
154
  loadForYou(locale),
126
155
  loadNewActions(session.ctx),
156
+ loadMissingAgreements(session.ctx, locale),
127
157
  ])
128
158
  : [
129
159
  { items: [], failed: false },
130
160
  { items: [], failed: false, now: "" },
131
161
  { actions: [], failed: false },
162
+ [],
132
163
  ];
133
164
 
134
165
  const dateFormat = new Intl.DateTimeFormat(locale, {
@@ -158,6 +189,17 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
158
189
  return (
159
190
  // The phone's top bar already clears the canvas — no top inset there.
160
191
  <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
+ />
161
203
  {/* The page's one h1, on both phone tabs; visually hidden on the phone,
162
204
  which has no large title (§5.15). New sits beside it (#8233 D4). */}
163
205
  <div className="flex items-center justify-between gap-2">
@@ -0,0 +1,65 @@
1
+ "use server";
2
+
3
+ /**
4
+ * A/privacy/actions.ts — the member Privacy and Notifications tabs' one
5
+ * consent write path (#8438 D14, D15, spec
6
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md).
7
+ *
8
+ * `setConsentAction` posts one answer through `POST /v1/member/consents`
9
+ * (`member.setConsent`) and answers in `ConsentSwitchList`'s own shape, so
10
+ * both tabs' switch lists can call it directly with no adapter. The
11
+ * banner's visitor id (`ailk-vid`) rides along as `x-ailk-consent-visitor`
12
+ * when present, linking its cookie choices to the client record this write
13
+ * resolves or creates (D6, D11, D12) — never failing the write when it is
14
+ * absent or invalid.
15
+ */
16
+
17
+ import { member } from "@working-theory/api-client";
18
+ import type { ConsentSwitchChangeResult } from "@working-theory/templates/account";
19
+ import { cookies } from "next/headers";
20
+ import { getLocale, getTranslations } from "next-intl/server";
21
+
22
+ import { consentVisitorHeader, readConsentVisitor } from "../../../../../lib/consent-visitor";
23
+ import { forwardedCookieInit } from "../../../../../lib/server-api";
24
+
25
+ function formatDate(iso: string, locale: string): string {
26
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
27
+ }
28
+
29
+ export async function setConsentAction(
30
+ purposeId: string,
31
+ granted: boolean,
32
+ ): Promise<ConsentSwitchChangeResult> {
33
+ const [t, locale] = await Promise.all([
34
+ getTranslations("account"),
35
+ getLocale(),
36
+ ]);
37
+
38
+ try {
39
+ const base = await forwardedCookieInit();
40
+ let visitorHeaders: Record<string, string> = {};
41
+ try {
42
+ const visitorId = readConsentVisitor(await cookies());
43
+ if (visitorId) visitorHeaders = consentVisitorHeader(visitorId);
44
+ } catch {
45
+ // No request scope to read, or no visitor cookie — write with none.
46
+ }
47
+
48
+ const res = await member.setConsent(
49
+ { purposeId, granted, locale },
50
+ { headers: { ...base.headers, ...visitorHeaders } },
51
+ );
52
+ if (!res.ok) {
53
+ return { ok: false, message: t("settings.privacy.unavailable") };
54
+ }
55
+
56
+ const state = res.body.purpose.state;
57
+ const at = state?.at ?? new Date().toISOString();
58
+ const dateLine = state?.granted
59
+ ? t("settings.privacy.given", { date: formatDate(at, locale) })
60
+ : t("settings.privacy.withdrawn", { date: formatDate(at, locale) });
61
+ return { ok: true, dateLine };
62
+ } catch {
63
+ return { ok: false, message: t("settings.privacy.unavailable") };
64
+ }
65
+ }