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.
- package/README.md +84 -7
- package/component-catalog.json +184 -6
- package/component-catalog.md +20 -9
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +76 -22
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +78 -2
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +123 -1
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +53 -0
- package/dist/update.js +606 -0
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/README.md +26 -25
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +18 -10
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/CLAUDE.md +26 -1
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
- package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +46 -4
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +4 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/deck-page.ts +186 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +83 -9
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +48 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/__tests__/seed.test.ts +39 -2
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +29 -0
- package/templates/project.yaml +6 -3
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +35 -35
- 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
|
+
}
|