create-ailk 0.19.0 → 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.
- package/README.md +64 -7
- package/dist/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- 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/_gitignore +162 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -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/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -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/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- 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 +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -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 +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- 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/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- 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 +95 -8
- 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/_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__/account-frame-sign-out.test.tsx +175 -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__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -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__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- 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 +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- 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/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- 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/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -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/robots.txt/route.ts +13 -10
- package/templates/apps/web/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- 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/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -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/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* artifacts/artifact-actions.ts — the review write (#8234, spec
|
|
5
5
|
* docs/specs/8234-member-artifacts-account-artifacts-and-spec.md D6; the
|
|
6
|
-
* write is #8170's `POST /v1/member/artifacts/:pieceId/review`)
|
|
6
|
+
* write is #8170's `POST /v1/member/artifacts/:pieceId/review`) and a
|
|
7
|
+
* proposal's payment (#8516, spec
|
|
8
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19; the write is
|
|
9
|
+
* `POST /v1/member/artifacts/:pieceId/payment`).
|
|
7
10
|
*
|
|
8
|
-
*
|
|
11
|
+
* `submitArtifactReview`, the review form's action, posts
|
|
9
12
|
* Approve or Request changes on a piece's latest version through
|
|
10
13
|
* `member.reviewArtifact` with `forwardedCookieInit()`'s init as built — the
|
|
11
14
|
* session cookie plus the request's `Origin` on a single site, the hub
|
|
@@ -15,22 +18,38 @@
|
|
|
15
18
|
* `pieceId` and `version` are untrusted: they are shape-checked here, and the
|
|
16
19
|
* API scopes them to the Person the credential resolves.
|
|
17
20
|
*
|
|
21
|
+
* A proposal's payment (#8516 D19). An approval whose answered artifact owes
|
|
22
|
+
* a payment (`paymentDue`) goes straight on to it; `payForProposal`, the Pay
|
|
23
|
+
* now form's action, does the same from the artifact page. Both run the one
|
|
24
|
+
* private `startProposalPayment`: `member.payArtifact` with the same init,
|
|
25
|
+
* then `redirect()` — only to a URL `isStripeCheckoutUrl` admits, so the
|
|
26
|
+
* client is never sent anywhere but Stripe's hosted Checkout page. The
|
|
27
|
+
* amount is the API's, from the stored proposal; nothing posted here names
|
|
28
|
+
* one. Any other outcome is `payment_failed`, and the review stays recorded,
|
|
29
|
+
* so the page offers Pay now. `redirect()` throws `NEXT_REDIRECT` by design,
|
|
30
|
+
* so it is called outside any try/catch (`../actions.ts`'s idiom).
|
|
31
|
+
*
|
|
18
32
|
* Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
|
|
19
33
|
* parity test reads that suffix, and the member writes have no MCP twin by
|
|
20
34
|
* decision (8170's header). The reads the pages share live in
|
|
21
35
|
* `artifact-data.ts`, never here — every export of this module is a POST
|
|
22
|
-
* endpoint. A log line carries `err.name` only — never the comment
|
|
36
|
+
* endpoint. A log line carries `err.name` only — never the comment, an id
|
|
37
|
+
* or a Checkout URL.
|
|
23
38
|
*/
|
|
24
39
|
|
|
25
40
|
import { member } from "@working-theory/api-client";
|
|
26
41
|
import {
|
|
42
|
+
isStripeCheckoutUrl,
|
|
27
43
|
memberReviewBodySchema,
|
|
28
44
|
memberReviewParamsSchema,
|
|
29
45
|
type MemberReviewBody,
|
|
30
46
|
} from "@working-theory/validation";
|
|
47
|
+
import { redirect } from "next/navigation";
|
|
31
48
|
|
|
32
49
|
import { forwardedCookieInit } from "../../../../../lib/server-api";
|
|
33
50
|
|
|
51
|
+
import { paymentDue } from "./artifact-data";
|
|
52
|
+
|
|
34
53
|
/** What the review form shows after a submit. */
|
|
35
54
|
export type ArtifactReviewState =
|
|
36
55
|
| { kind: "idle" }
|
|
@@ -42,7 +61,8 @@ export type ArtifactReviewState =
|
|
|
42
61
|
| "closed"
|
|
43
62
|
| "not_latest"
|
|
44
63
|
| "rate_limited"
|
|
45
|
-
| "failed"
|
|
64
|
+
| "failed"
|
|
65
|
+
| "payment_failed";
|
|
46
66
|
};
|
|
47
67
|
|
|
48
68
|
export type ArtifactReviewReason = Extract<
|
|
@@ -50,6 +70,12 @@ export type ArtifactReviewReason = Extract<
|
|
|
50
70
|
{ kind: "error" }
|
|
51
71
|
>["reason"];
|
|
52
72
|
|
|
73
|
+
/** What the Pay now form shows after a submit that did not leave for Stripe. */
|
|
74
|
+
export type ProposalPayState =
|
|
75
|
+
{ kind: "idle" } | { kind: "error"; reason: "payment_failed" };
|
|
76
|
+
|
|
77
|
+
const PAYMENT_FAILED = { kind: "error", reason: "payment_failed" } as const;
|
|
78
|
+
|
|
53
79
|
function error(reason: ArtifactReviewReason): ArtifactReviewState {
|
|
54
80
|
return { kind: "error", reason };
|
|
55
81
|
}
|
|
@@ -119,5 +145,40 @@ export async function submitArtifactReview(
|
|
|
119
145
|
return error("failed");
|
|
120
146
|
}
|
|
121
147
|
if (!res.ok) return error(refusalReason(res.status, res.body));
|
|
148
|
+
if (body.review === "approved" && paymentDue(res.body.artifact))
|
|
149
|
+
return startProposalPayment(pieceId);
|
|
122
150
|
return { kind: "reviewed" };
|
|
123
151
|
}
|
|
152
|
+
|
|
153
|
+
/** Pay now: open the approved proposal's payment and go to it. */
|
|
154
|
+
export async function payForProposal(
|
|
155
|
+
_prev: ProposalPayState,
|
|
156
|
+
formData: FormData,
|
|
157
|
+
): Promise<ProposalPayState> {
|
|
158
|
+
const pieceId = field(formData, "pieceId");
|
|
159
|
+
if (!memberReviewParamsSchema.safeParse({ pieceId }).success)
|
|
160
|
+
return PAYMENT_FAILED;
|
|
161
|
+
return startProposalPayment(pieceId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ask the API for the proposal's Stripe Checkout page and redirect there.
|
|
166
|
+
* Never returns on success; anything short of a Stripe Checkout URL is
|
|
167
|
+
* `payment_failed`.
|
|
168
|
+
*/
|
|
169
|
+
async function startProposalPayment(
|
|
170
|
+
pieceId: string,
|
|
171
|
+
): Promise<typeof PAYMENT_FAILED> {
|
|
172
|
+
let url: string | null = null;
|
|
173
|
+
try {
|
|
174
|
+
const res = await member.payArtifact(pieceId, await forwardedCookieInit());
|
|
175
|
+
if (res.ok && isStripeCheckoutUrl(res.body.url)) url = res.body.url;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(
|
|
178
|
+
`[account] payment failed errName=${err instanceof Error ? err.name : "unknown"}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (url === null) return PAYMENT_FAILED;
|
|
182
|
+
// Outside any try/catch on purpose — see the file header.
|
|
183
|
+
redirect(url);
|
|
184
|
+
}
|
|
@@ -64,6 +64,23 @@ export function isReviewable(a: MemberArtifact): boolean {
|
|
|
64
64
|
return a.approvedAt === null && a.versions[0]?.review === null;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* True for a proposal (#8516 D19). An older hub serves no `kind` (D4: the
|
|
69
|
+
* key is optional), and such an artifact is a deliverable.
|
|
70
|
+
*/
|
|
71
|
+
export function isProposal(a: MemberArtifact): boolean {
|
|
72
|
+
return a.kind === "proposal";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* True while an approved, priced proposal waits on the client's payment
|
|
77
|
+
* (#8516 D19). The API serves `due` only where a payment surface answers,
|
|
78
|
+
* so anything else — `null`, `paid` or an older hub's missing key — is not.
|
|
79
|
+
*/
|
|
80
|
+
export function paymentDue(a: MemberArtifact): boolean {
|
|
81
|
+
return a.payment?.status === "due";
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
/** The artifact page of one piece. */
|
|
68
85
|
export function artifactHref(locale: string, pieceId: string): string {
|
|
69
86
|
return localeHref(
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* page's structure: a second column with an "All artifacts" count and a
|
|
9
9
|
* Review facet group (Ready for review · Changes requested · Approved),
|
|
10
10
|
* each counted over every artifact with zeros kept, and a table of Title ·
|
|
11
|
-
* Latest · Delivered · Review in the read's order — newest delivery first
|
|
11
|
+
* Latest · Delivered · Review in the read's order — newest delivery first;
|
|
12
|
+
* the Review column is wide enough for its longest state label (#8361).
|
|
12
13
|
* Below `md` the column becomes a filter select and the table becomes the
|
|
13
14
|
* phone row list.
|
|
14
15
|
*
|
|
@@ -17,6 +18,10 @@
|
|
|
17
18
|
* failed read renders today's error row with Retry and no frame (spec 7423
|
|
18
19
|
* D12). Nothing here tests the topology: the same file serves a single site
|
|
19
20
|
* and a hub-mode spoke.
|
|
21
|
+
*
|
|
22
|
+
* A proposal (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
|
|
23
|
+
* D19) reads "Proposal · Version <n>" in its caption and Latest cell; a
|
|
24
|
+
* deliverable reads as before.
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
27
|
import {
|
|
@@ -35,6 +40,7 @@ import { ErrorRow } from "../error-row";
|
|
|
35
40
|
import {
|
|
36
41
|
artifactHref,
|
|
37
42
|
artifactState,
|
|
43
|
+
isProposal,
|
|
38
44
|
loadArtifacts,
|
|
39
45
|
STATE_KEYS,
|
|
40
46
|
type ArtifactState,
|
|
@@ -119,14 +125,18 @@ export default async function AccountArtifactsPage({
|
|
|
119
125
|
timeZone: "UTC",
|
|
120
126
|
}).format(new Date(latest.deliveredAt));
|
|
121
127
|
const reviewWord = t(STATE_KEYS[state]);
|
|
128
|
+
const versionWord = t("artifacts.version", { version: latest.version });
|
|
129
|
+
const latestWord = isProposal(artifact)
|
|
130
|
+
? `${t("artifacts.proposal.caption")} · ${versionWord}`
|
|
131
|
+
: versionWord;
|
|
122
132
|
return {
|
|
123
133
|
id: artifact.id,
|
|
124
134
|
title: artifact.title,
|
|
125
135
|
href: artifactHref(locale, artifact.id),
|
|
126
136
|
facet: state,
|
|
127
|
-
caption: `${
|
|
137
|
+
caption: `${latestWord} · ${reviewWord}`,
|
|
128
138
|
cells: {
|
|
129
|
-
latest:
|
|
139
|
+
latest: latestWord,
|
|
130
140
|
delivered,
|
|
131
141
|
review: reviewWord,
|
|
132
142
|
},
|
|
@@ -156,12 +166,8 @@ export default async function AccountArtifactsPage({
|
|
|
156
166
|
width: "1/6",
|
|
157
167
|
minWidth: "28",
|
|
158
168
|
},
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
header: t("artifacts.columns.review"),
|
|
162
|
-
width: "1/6",
|
|
163
|
-
minWidth: "32",
|
|
164
|
-
},
|
|
169
|
+
// Sized by the frame from the longest state label (#8361).
|
|
170
|
+
{ key: "review", header: t("artifacts.columns.review"), fit: "facets" },
|
|
165
171
|
]}
|
|
166
172
|
rows={artifacts.map(row)}
|
|
167
173
|
labels={{
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* /[locale]/account/billing — User settings › Billing (#8339 D8; builds on
|
|
3
3
|
* #8232 D9, AC-MAA-19).
|
|
4
4
|
*
|
|
5
|
-
* RSC,
|
|
6
|
-
*
|
|
5
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
6
|
+
* four-tab settings frame (D7) — standalone, the Admin's shape. Lays out like the Admin's
|
|
7
7
|
* organization Billing page (`OrgBillingPanel`'s `current-plan` layout):
|
|
8
8
|
* the shared `CurrentPlanCard` (@working-theory/templates/account, spec
|
|
9
9
|
* 8339 D5), by `loadBilling()`'s answer (`billing-source.ts`, the billing
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* - a subscription — the plan (or `unnamedPlan`), the status plus when
|
|
13
13
|
* access ends (when set, C4 — `subscription.me` carries no price), the
|
|
14
|
-
* card on file, then Manage
|
|
14
|
+
* card on file, then Manage billing when a portal is available;
|
|
15
15
|
* - no subscription — the same card shape with the plain line and no
|
|
16
16
|
* button (C5);
|
|
17
17
|
* - a failed read — the area's error row with Retry, never a false "you
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* general/contact-actions.ts — User settings › General's contact-details write
|
|
5
|
+
* (#8435, spec docs/specs/8435-contact-details-on-the-client-spec.md D13; the
|
|
6
|
+
* route is `PATCH /v1/member/profile/contact`).
|
|
7
|
+
*
|
|
8
|
+
* `saveContact` replaces the member's contact details on THIS site —
|
|
9
|
+
* `member.updateContact`, never the hub account's name and photo
|
|
10
|
+
* (`profile-actions.ts`'s `saveProfile` is that) and never another site's
|
|
11
|
+
* record. It sends `forwardedCookieInit()`'s init: on a single site the session
|
|
12
|
+
* cookie plus the request's `Origin`, which `apps/api`'s cookie CSRF check
|
|
13
|
+
* needs; on a hub-mode spoke the hub credential. It takes no identity
|
|
14
|
+
* argument — who comes from the forwarded credential alone.
|
|
15
|
+
*
|
|
16
|
+
* It answers `{ ok: true }` or a refusal `reason` the form maps to its own
|
|
17
|
+
* line: `telephone` (a number that is not E.164, `400 telephone_invalid`),
|
|
18
|
+
* `invalid` (any other refused body), `unverified` (`403`), `unavailable`
|
|
19
|
+
* (`404`, `503`, or `409 contact_unavailable` — a Person of that email held by
|
|
20
|
+
* another account), else `failed`. Logs carry a status or `err.name` only —
|
|
21
|
+
* never a name, a number, an address or an email.
|
|
22
|
+
*
|
|
23
|
+
* Not a `*.actions.ts` file and no `*Action`-suffixed export: the write has no
|
|
24
|
+
* MCP twin by decision (8435 D12), and apps/mcp's parity test reads that
|
|
25
|
+
* suffix.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { member } from "@working-theory/api-client";
|
|
29
|
+
|
|
30
|
+
import { forwardedCookieInit } from "../../../../../lib/server-api";
|
|
31
|
+
|
|
32
|
+
/** PII-safe error identity for logs (the `profile-actions.ts` idiom: `err.name` only). */
|
|
33
|
+
function errorName(err: unknown): string {
|
|
34
|
+
return err instanceof Error ? err.name : "unknown";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `error.code` of a member write's refusal body, when it has one. */
|
|
38
|
+
function errorCode(body: unknown): string | null {
|
|
39
|
+
if (typeof body !== "object" || body === null) return null;
|
|
40
|
+
const error = (body as { error?: unknown }).error;
|
|
41
|
+
if (typeof error !== "object" || error === null) return null;
|
|
42
|
+
const code = (error as { code?: unknown }).code;
|
|
43
|
+
return typeof code === "string" ? code : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SaveContactResult =
|
|
47
|
+
| { ok: true }
|
|
48
|
+
| {
|
|
49
|
+
ok: false;
|
|
50
|
+
reason: "telephone" | "invalid" | "unverified" | "unavailable" | "failed";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function saveContact(
|
|
54
|
+
input: Parameters<typeof member.updateContact>[0],
|
|
55
|
+
): Promise<SaveContactResult> {
|
|
56
|
+
// Action input is untrusted: a hand-built call may send anything, and the
|
|
57
|
+
// API's own schema is the validator — a non-object is refused before any call.
|
|
58
|
+
if (typeof input !== "object" || input === null)
|
|
59
|
+
return { ok: false, reason: "invalid" };
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const res = await member.updateContact(input, await forwardedCookieInit());
|
|
63
|
+
if (res.ok) return { ok: true };
|
|
64
|
+
console.error(`[contact-actions] saveContact status=${res.status}`);
|
|
65
|
+
if (res.status === 400) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason:
|
|
69
|
+
errorCode(res.body) === "telephone_invalid" ? "telephone" : "invalid",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (res.status === 403) return { ok: false, reason: "unverified" };
|
|
73
|
+
if (res.status === 404 || res.status === 409 || res.status === 503) {
|
|
74
|
+
return { ok: false, reason: "unavailable" };
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, reason: "failed" };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error(`[contact-actions] saveContact errName=${errorName(err)}`);
|
|
79
|
+
return { ok: false, reason: "failed" };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -2,26 +2,34 @@
|
|
|
2
2
|
* /[locale]/account/general — User settings › General (#8339 D8; builds on
|
|
3
3
|
* #8232 D2).
|
|
4
4
|
*
|
|
5
|
-
* RSC,
|
|
6
|
-
*
|
|
5
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
6
|
+
* settings frame (D7; five tabs since #8438 D14) — standalone, the Admin's
|
|
7
|
+
* shape. Renders the shared
|
|
7
8
|
* `AccountSettings` composition (@working-theory/templates/account, spec
|
|
8
9
|
* 8339 D4) — the same General form the Admin renders — through the client
|
|
9
10
|
* adapter `GeneralSettings` (`profile-form.tsx`). Below the form:
|
|
10
11
|
*
|
|
11
|
-
* -
|
|
12
|
+
* - the contact details (`AccountContactDetails`, #8435 D13) — only on the
|
|
13
|
+
* member path, seeded from `member.contact` and saved by `saveContact`;
|
|
12
14
|
* - any section a module contributes (`accountSettingsSections`);
|
|
13
15
|
* - `BillingAnchor`, which forwards a `#billing` fragment to Billing.
|
|
14
16
|
*
|
|
15
|
-
* Sign-in moved to Security
|
|
16
|
-
* C6)
|
|
17
|
+
* Sign-in moved to Security, cookie settings moved to Notifications then to
|
|
18
|
+
* Privacy (#8339 C6), and Request account deletion moved to Privacy (#8438
|
|
19
|
+
* D16) — General no longer renders any of the three.
|
|
17
20
|
*
|
|
18
21
|
* Whether the profile and deletion go through the member API is
|
|
19
22
|
* `loadProfileSource` (D3), never a topology test here.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
+
import { member } from "@working-theory/api-client";
|
|
26
|
+
import {
|
|
27
|
+
AccountContactDetails,
|
|
28
|
+
AccountSettingsFrame,
|
|
29
|
+
AccountSettingsPane,
|
|
30
|
+
type AccountContact,
|
|
31
|
+
} from "@working-theory/templates/account";
|
|
32
|
+
import { Separator, Text } from "@working-theory/ui/primitives";
|
|
25
33
|
import type { Metadata } from "next";
|
|
26
34
|
import { getTranslations } from "next-intl/server";
|
|
27
35
|
import type { ReactElement } from "react";
|
|
@@ -32,7 +40,7 @@ import { siteThemeModeCount } from "../settings/theme-modes";
|
|
|
32
40
|
import { memberSettingsFrame, SettingsSection, type Translate } from "../settings-frame";
|
|
33
41
|
|
|
34
42
|
import { BillingAnchor } from "./billing-anchor";
|
|
35
|
-
import {
|
|
43
|
+
import { saveContact } from "./contact-actions";
|
|
36
44
|
import { GeneralSettings } from "./profile-form";
|
|
37
45
|
import { loadProfileSource } from "./profile-source";
|
|
38
46
|
|
|
@@ -70,6 +78,19 @@ export default async function AccountGeneralPage({ params }: Props): Promise<Rea
|
|
|
70
78
|
const { ctx } = session;
|
|
71
79
|
const source = await loadProfileSource(ctx);
|
|
72
80
|
|
|
81
|
+
// #8435 D13 — the person's confirmed contact on this site. A failed read
|
|
82
|
+
// renders the section's "unavailable" line, never the form: a form seeded
|
|
83
|
+
// from nothing would offer to replace details it never showed.
|
|
84
|
+
let contactRead: { ok: true; contact: AccountContact | null } | { ok: false } | null = null;
|
|
85
|
+
if (source.via === "member") {
|
|
86
|
+
try {
|
|
87
|
+
const res = await member.contact(ctx.init);
|
|
88
|
+
contactRead = res.ok ? { ok: true, contact: res.body.contact } : { ok: false };
|
|
89
|
+
} catch {
|
|
90
|
+
contactRead = { ok: false };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
return (
|
|
74
95
|
<GeneralSettings
|
|
75
96
|
frame={memberSettingsFrame(locale, t as unknown as Translate, "general")}
|
|
@@ -102,24 +123,46 @@ export default async function AccountGeneralPage({ params }: Props): Promise<Rea
|
|
|
102
123
|
}
|
|
103
124
|
via={source.via}
|
|
104
125
|
>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
{contactRead ? (
|
|
127
|
+
<>
|
|
128
|
+
<Separator />
|
|
129
|
+
<div data-testid="account-contact-section">
|
|
130
|
+
{contactRead.ok ? (
|
|
131
|
+
<AccountContactDetails
|
|
132
|
+
contact={contactRead.contact}
|
|
133
|
+
onSave={saveContact}
|
|
134
|
+
labels={{
|
|
135
|
+
title: t("settings.general.contact.title"),
|
|
136
|
+
lead: t("settings.general.contact.lead"),
|
|
137
|
+
givenName: t("settings.general.contact.givenName"),
|
|
138
|
+
familyName: t("settings.general.contact.familyName"),
|
|
139
|
+
telephone: t("settings.general.contact.telephone"),
|
|
140
|
+
address: t("settings.general.contact.address"),
|
|
141
|
+
companyName: t("settings.general.contact.companyName"),
|
|
142
|
+
jobTitle: t("settings.general.contact.jobTitle"),
|
|
143
|
+
save: t("settings.general.contact.save"),
|
|
144
|
+
saving: t("settings.general.contact.saving"),
|
|
145
|
+
saved: t("settings.general.contact.saved"),
|
|
146
|
+
addressIncomplete: t("settings.general.contact.addressIncomplete"),
|
|
147
|
+
errors: {
|
|
148
|
+
telephone: t("settings.general.contact.errors.telephone"),
|
|
149
|
+
invalid: t("settings.general.contact.errors.invalid"),
|
|
150
|
+
unverified: t("settings.general.contact.errors.unverified"),
|
|
151
|
+
unavailable: t("settings.general.contact.errors.unavailable"),
|
|
152
|
+
failed: t("settings.general.contact.errors.failed"),
|
|
153
|
+
},
|
|
154
|
+
}}
|
|
155
|
+
/>
|
|
156
|
+
) : (
|
|
157
|
+
<span data-testid="account-contact-unavailable">
|
|
158
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
159
|
+
{t("settings.general.contact.unavailable")}
|
|
160
|
+
</Text>
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</>
|
|
165
|
+
) : null}
|
|
123
166
|
{accountSettingsSections.map((section) => (
|
|
124
167
|
<SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
|
|
125
168
|
{section.render(ctx)}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The account layout — the base account area's frame (#7430, spec 7423 U4 ·
|
|
3
3
|
* D4, D13; DESIGN.md §2b).
|
|
4
4
|
*
|
|
5
|
-
* RSC. Every `/account` route
|
|
6
|
-
*
|
|
5
|
+
* RSC. Every `/account` route but the four Settings tabs renders inside
|
|
6
|
+
* `SignedInShell` (through the client `AccountFrame`; the tabs draw their own
|
|
7
|
+
* standalone settings frame, #8430): the site's logo as the rail's top tile, linking
|
|
7
8
|
* home; the rail's rows — Inbox, a Library group holding Artifacts, then
|
|
8
9
|
* Settings (#8231 D1); a footer of the avatar and ? alone;
|
|
9
10
|
* the card avatar menu (name, then Settings and Log out); the ? help menu
|
|
@@ -16,6 +17,10 @@
|
|
|
16
17
|
* area's own "Add your name" item (`loadForYou`, deduped per request with
|
|
17
18
|
* the Inbox's phone For you tab); with no items there is no aside.
|
|
18
19
|
*
|
|
20
|
+
* #8465 — `SIGN_OUT_ENDPOINT` (set only on a spoke that signs in through its
|
|
21
|
+
* hub) and `SIGN_IN_PATH` ride down as props; the client frame cannot import
|
|
22
|
+
* the generated seam, and the signed-in Nav reads it the same way.
|
|
23
|
+
*
|
|
19
24
|
* The `(authed)` layout above owns the signed-out gate, so a missing session
|
|
20
25
|
* here is a race (it expired between the two layouts): the page renders
|
|
21
26
|
* unframed and degrades on its own.
|
|
@@ -26,11 +31,22 @@ import { getTranslations } from "next-intl/server";
|
|
|
26
31
|
import type { ReactElement, ReactNode } from "react";
|
|
27
32
|
|
|
28
33
|
import { siteLogo } from "../../../../lib/site-logo";
|
|
34
|
+
import { SIGN_IN_PATH, SIGN_OUT_ENDPOINT } from "../../../../lib/topology.generated";
|
|
29
35
|
|
|
30
36
|
import { accountSession, loadForYou } from "./account-data";
|
|
31
37
|
import { AccountFrame } from "./account-frame";
|
|
32
38
|
import { forYouLabels } from "./for-you-labels";
|
|
33
39
|
|
|
40
|
+
/** The value when it parses as an https URL, else undefined: operator config is rendered as an href. */
|
|
41
|
+
function httpsUrl(value: string | undefined): string | undefined {
|
|
42
|
+
if (!value) return undefined;
|
|
43
|
+
try {
|
|
44
|
+
return new URL(value).protocol === "https:" ? value : undefined;
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
interface Props {
|
|
35
51
|
children: ReactNode;
|
|
36
52
|
params: Promise<{ locale: string }>;
|
|
@@ -47,7 +63,7 @@ export default async function AccountLayout({
|
|
|
47
63
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
48
64
|
const forYou = await loadForYou(locale);
|
|
49
65
|
const { name, email } = session.ctx.user;
|
|
50
|
-
const helpCenterUrl = process.env.AILK_HELP_CENTER_URL;
|
|
66
|
+
const helpCenterUrl = httpsUrl(process.env.AILK_HELP_CENTER_URL);
|
|
51
67
|
|
|
52
68
|
return (
|
|
53
69
|
<AccountFrame
|
|
@@ -65,6 +81,12 @@ export default async function AccountLayout({
|
|
|
65
81
|
: []),
|
|
66
82
|
]}
|
|
67
83
|
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
|
+
: {})}
|
|
68
90
|
labels={{
|
|
69
91
|
home: t("frame.home"),
|
|
70
92
|
inbox: t("frame.inbox"),
|
|
@@ -72,6 +94,7 @@ export default async function AccountLayout({
|
|
|
72
94
|
settings: t("frame.settings"),
|
|
73
95
|
library: t("frame.library"),
|
|
74
96
|
artifacts: t("frame.artifacts"),
|
|
97
|
+
signOutFailed: t("frame.signOutFailed"),
|
|
75
98
|
signOut: t("frame.signOut"),
|
|
76
99
|
help: t("frame.help"),
|
|
77
100
|
accountMenu: t("frame.accountMenu"),
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* /[locale]/account/notifications — User settings › Notifications (#8339
|
|
3
|
-
* D8
|
|
3
|
+
* D8; #8438 D15 — what we send you).
|
|
4
4
|
*
|
|
5
|
-
* RSC,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
6
|
+
* settings frame (D7) — standalone, the Admin's shape. Always renders
|
|
7
|
+
* (decision A, operator 2026-10-12): a `ConsentSwitchList` of the site
|
|
8
|
+
* registry's `area: "communications"` purposes (`consent` and
|
|
9
|
+
* `preference`), each read from `GET /v1/member/consents`'s OWN state and
|
|
10
|
+
* `effective` default (no row ⇒ on for a preference, off for a consent).
|
|
11
|
+
* With none declared, one `account-notifications-empty` line.
|
|
12
|
+
*
|
|
13
|
+
* The cookie-settings link moved to Privacy (#8438 D14, C6) — this tab no
|
|
14
|
+
* longer renders it.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
17
|
+
import { member } from "@working-theory/api-client";
|
|
18
|
+
import {
|
|
19
|
+
AccountSettingsFrame,
|
|
20
|
+
AccountSettingsPane,
|
|
21
|
+
ConsentSwitchList,
|
|
22
|
+
type ConsentSwitchRow,
|
|
23
|
+
} from "@working-theory/templates/account";
|
|
14
24
|
import type { Metadata } from "next";
|
|
15
25
|
import { getTranslations } from "next-intl/server";
|
|
16
26
|
import type { ReactElement } from "react";
|
|
17
27
|
|
|
18
28
|
import { accountSession } from "../account-data";
|
|
29
|
+
import { setConsentAction } from "../privacy/actions";
|
|
19
30
|
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
20
31
|
|
|
32
|
+
|
|
21
33
|
interface Props {
|
|
22
34
|
params: Promise<{ locale: string }>;
|
|
23
35
|
}
|
|
@@ -25,7 +37,7 @@ interface Props {
|
|
|
25
37
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
26
38
|
const { locale } = await params;
|
|
27
39
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
28
|
-
return { title: t("settings.notifications.title"), description: t("settings.
|
|
40
|
+
return { title: t("settings.notifications.title"), description: t("settings.notifications.lead") };
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export default async function AccountNotificationsPage({ params }: Props): Promise<ReactElement> {
|
|
@@ -35,23 +47,57 @@ export default async function AccountNotificationsPage({ params }: Props): Promi
|
|
|
35
47
|
const frame = memberSettingsFrame(locale, t as unknown as Translate, "notifications");
|
|
36
48
|
const name = session?.ctx.user.name || session?.ctx.user.email || "";
|
|
37
49
|
|
|
50
|
+
let rows: ConsentSwitchRow[] = [];
|
|
51
|
+
let unavailable = false;
|
|
52
|
+
if (session) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await member.consents(session.ctx.init, { locale });
|
|
55
|
+
if (res.ok) {
|
|
56
|
+
rows = res.body.purposes
|
|
57
|
+
.filter((purpose) => purpose.area === "communications")
|
|
58
|
+
.map((purpose) => ({
|
|
59
|
+
id: purpose.id,
|
|
60
|
+
label: purpose.statement,
|
|
61
|
+
granted: purpose.effective,
|
|
62
|
+
...(purpose.description ? { description: purpose.description } : {}),
|
|
63
|
+
...(purpose.state
|
|
64
|
+
? {
|
|
65
|
+
dateLine: purpose.state.granted
|
|
66
|
+
? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
|
|
67
|
+
: t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
}));
|
|
71
|
+
} else {
|
|
72
|
+
unavailable = true;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
unavailable = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
return (
|
|
39
80
|
<AccountSettingsFrame {...frame} name={name}>
|
|
40
81
|
<AccountSettingsPane
|
|
41
82
|
id="account-settings-notifications"
|
|
42
83
|
title={t("settings.notifications.title")}
|
|
43
|
-
lead={t("settings.
|
|
84
|
+
lead={t("settings.notifications.lead")}
|
|
44
85
|
>
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{t("settings.
|
|
52
|
-
|
|
53
|
-
|
|
86
|
+
{unavailable ? (
|
|
87
|
+
<span data-testid="account-notifications-unavailable">{t("settings.privacy.unavailable")}</span>
|
|
88
|
+
) : (
|
|
89
|
+
<ConsentSwitchList
|
|
90
|
+
rows={rows}
|
|
91
|
+
onChange={setConsentAction}
|
|
92
|
+
emptyLine={t("settings.notifications.empty")}
|
|
93
|
+
testIdPrefix="account-notifications"
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
54
96
|
</AccountSettingsPane>
|
|
55
97
|
</AccountSettingsFrame>
|
|
56
98
|
);
|
|
57
99
|
}
|
|
100
|
+
|
|
101
|
+
function formatDate(iso: string, locale: string): string {
|
|
102
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
|
|
103
|
+
}
|