create-ailk 0.18.0 → 0.19.1
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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +11 -3
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +32 -0
- package/dist/lib/scaffold-render-sweep.js +76 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +128 -3
- package/dist/programmatic.js +11 -1
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +170 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +88 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +31 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -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__/deliverable-asset-store.test.ts +38 -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/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +176 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/middleware/auth.ts +214 -46
- package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
- 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 +19 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- 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-rate-limit.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
- package/templates/apps/api/src/server.ts +218 -16
- 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 +173 -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/consent.ts +488 -3
- package/templates/apps/api/src/services/lead-routing.ts +135 -14
- 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/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- 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__/billing.test.tsx +302 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- 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 +232 -29
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
- 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/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +25 -2
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/auth-email.ts +45 -10
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +194 -33
- package/templates/database/CHANGELOG.md +37 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- 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__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +166 -25
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- 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/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use client"; // reads meeting types and slots on demand and posts a controlled form
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScheduleService — the New menu's Schedule service popup (#8233, spec D5,
|
|
5
|
+
* D8, D10; AC-MAA-10). On open it reads the meeting types the provider
|
|
6
|
+
* offers its clients (`readMeetingTypes`) and lists only those; picking one
|
|
7
|
+
* reads that type's open times for the next 14 days in the browser's time
|
|
8
|
+
* zone (`readSlots`, which computes the range on the server). Book posts the
|
|
9
|
+
* type, the slot's start as served, the time zone and optional notes to
|
|
10
|
+
* `member.book` — no name and no email: the server takes the verified email
|
|
11
|
+
* and the name from the session.
|
|
12
|
+
*
|
|
13
|
+
* With no type offered it says no times are offered and, when Send message
|
|
14
|
+
* is on the menu, offers to switch to it. A taken slot (`slot_unavailable`)
|
|
15
|
+
* re-reads the picked type's times and keeps the type and the notes; every
|
|
16
|
+
* other refusal shows its plain message with everything kept.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
Button,
|
|
21
|
+
Label,
|
|
22
|
+
RadioGroup,
|
|
23
|
+
RadioGroupItem,
|
|
24
|
+
Text,
|
|
25
|
+
Textarea,
|
|
26
|
+
} from "@working-theory/ui/primitives";
|
|
27
|
+
import type { MemberMeetingType, SchedulingSlot } from "@working-theory/validation";
|
|
28
|
+
import {
|
|
29
|
+
startTransition,
|
|
30
|
+
useActionState,
|
|
31
|
+
useId,
|
|
32
|
+
useMemo,
|
|
33
|
+
useRef,
|
|
34
|
+
useState,
|
|
35
|
+
type FormEvent,
|
|
36
|
+
type ReactElement,
|
|
37
|
+
} from "react";
|
|
38
|
+
|
|
39
|
+
import { bookService, readMeetingTypes, readSlots, type NewActionState } from "../new-actions";
|
|
40
|
+
import {
|
|
41
|
+
NewPopupRefusal,
|
|
42
|
+
NewPopupStatus,
|
|
43
|
+
useNewMenuPopup,
|
|
44
|
+
useOpenRead,
|
|
45
|
+
type NewPopupLabels,
|
|
46
|
+
} from "../new-menu";
|
|
47
|
+
|
|
48
|
+
export interface ScheduleServiceLabels extends NewPopupLabels {
|
|
49
|
+
service: string;
|
|
50
|
+
/** Carries a literal `{minutes}`, filled per type here. */
|
|
51
|
+
duration: string;
|
|
52
|
+
time: string;
|
|
53
|
+
notes: string;
|
|
54
|
+
submit: string;
|
|
55
|
+
none: string;
|
|
56
|
+
suggest: string;
|
|
57
|
+
noSlots: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ScheduleServiceProps {
|
|
61
|
+
locale: string;
|
|
62
|
+
labels: ScheduleServiceLabels;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SEND_MESSAGE = "send-message";
|
|
66
|
+
|
|
67
|
+
export function ScheduleService({ locale, labels }: ScheduleServiceProps): ReactElement {
|
|
68
|
+
const popup = useNewMenuPopup();
|
|
69
|
+
const [read, retry] = useOpenRead(readMeetingTypes);
|
|
70
|
+
if (read === "loading" || read.kind === "error") {
|
|
71
|
+
return <NewPopupStatus failed={read !== "loading"} labels={labels} onRetry={retry} />;
|
|
72
|
+
}
|
|
73
|
+
if (read.items.length === 0) {
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col gap-4" data-testid="new-schedule-none">
|
|
76
|
+
<Text as="p" variant="body-sm">
|
|
77
|
+
{labels.none}
|
|
78
|
+
</Text>
|
|
79
|
+
{popup.has(SEND_MESSAGE) ? (
|
|
80
|
+
<div>
|
|
81
|
+
<Button
|
|
82
|
+
variant="outline"
|
|
83
|
+
className="max-md:min-h-11"
|
|
84
|
+
onClick={() => popup.open(SEND_MESSAGE)}
|
|
85
|
+
data-testid="new-schedule-suggest"
|
|
86
|
+
>
|
|
87
|
+
{labels.suggest}
|
|
88
|
+
</Button>
|
|
89
|
+
</div>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return <BookingForm types={read.items} locale={locale} labels={labels} />;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type Slots = { kind: "idle" | "loading" | "error" } | { kind: "ok"; slots: SchedulingSlot[] };
|
|
98
|
+
|
|
99
|
+
function BookingForm({
|
|
100
|
+
types,
|
|
101
|
+
locale,
|
|
102
|
+
labels,
|
|
103
|
+
}: {
|
|
104
|
+
types: readonly MemberMeetingType[];
|
|
105
|
+
locale: string;
|
|
106
|
+
labels: ScheduleServiceLabels;
|
|
107
|
+
}): ReactElement {
|
|
108
|
+
const popup = useNewMenuPopup();
|
|
109
|
+
const id = useId();
|
|
110
|
+
const timeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []);
|
|
111
|
+
const [slug, setSlug] = useState("");
|
|
112
|
+
const [start, setStart] = useState("");
|
|
113
|
+
const [notes, setNotes] = useState("");
|
|
114
|
+
const [slots, setSlots] = useState<Slots>({ kind: "idle" });
|
|
115
|
+
// Only the newest slots read lands: a quick second pick never shows the first's times.
|
|
116
|
+
const latestRead = useRef(0);
|
|
117
|
+
|
|
118
|
+
const loadSlots = (picked: string): void => {
|
|
119
|
+
const readId = ++latestRead.current;
|
|
120
|
+
setSlots({ kind: "loading" });
|
|
121
|
+
const land = (next: Slots): void => {
|
|
122
|
+
if (readId === latestRead.current) setSlots(next);
|
|
123
|
+
};
|
|
124
|
+
readSlots({ slug: picked, timeZone }).then(
|
|
125
|
+
(result) => land(result.kind === "ok" ? { kind: "ok", slots: result.slots } : { kind: "error" }),
|
|
126
|
+
() => land({ kind: "error" }),
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const [state, dispatch, pending] = useActionState<NewActionState, FormData>(
|
|
131
|
+
async (prev, formData) => {
|
|
132
|
+
const next = await bookService(prev, formData);
|
|
133
|
+
if (next.kind === "success") popup.close(true);
|
|
134
|
+
if (next.kind === "error" && next.code === "slot_unavailable") {
|
|
135
|
+
const picked = formData.get("slug");
|
|
136
|
+
setStart("");
|
|
137
|
+
if (typeof picked === "string") loadSlots(picked);
|
|
138
|
+
}
|
|
139
|
+
return next;
|
|
140
|
+
},
|
|
141
|
+
{ kind: "idle" },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
const formData = new FormData();
|
|
147
|
+
formData.set("slug", slug);
|
|
148
|
+
formData.set("start", start);
|
|
149
|
+
formData.set("timeZone", timeZone);
|
|
150
|
+
formData.set("notes", notes);
|
|
151
|
+
startTransition(() => dispatch(formData));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const dayFormat = new Intl.DateTimeFormat(locale, {
|
|
155
|
+
weekday: "long",
|
|
156
|
+
month: "long",
|
|
157
|
+
day: "numeric",
|
|
158
|
+
timeZone,
|
|
159
|
+
});
|
|
160
|
+
const timeFormat = new Intl.DateTimeFormat(locale, { timeStyle: "short", timeZone });
|
|
161
|
+
const days: { day: string; slots: { slot: SchedulingSlot; index: number }[] }[] = [];
|
|
162
|
+
if (slots.kind === "ok") {
|
|
163
|
+
slots.slots.forEach((slot, index) => {
|
|
164
|
+
const day = dayFormat.format(new Date(slot.start));
|
|
165
|
+
const last = days[days.length - 1];
|
|
166
|
+
if (last?.day === day) last.slots.push({ slot, index });
|
|
167
|
+
else days.push({ day, slots: [{ slot, index }] });
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<form onSubmit={onSubmit} className="flex flex-col gap-4" data-testid="new-schedule-form">
|
|
173
|
+
<div className="flex flex-col gap-2">
|
|
174
|
+
<Text as="p" id={`${id}-service`} variant="body-sm" className="font-semibold">
|
|
175
|
+
{labels.service}
|
|
176
|
+
</Text>
|
|
177
|
+
<RadioGroup
|
|
178
|
+
aria-labelledby={`${id}-service`}
|
|
179
|
+
value={slug}
|
|
180
|
+
onValueChange={(picked) => {
|
|
181
|
+
setSlug(picked);
|
|
182
|
+
setStart("");
|
|
183
|
+
loadSlots(picked);
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{types.map((type) => (
|
|
187
|
+
<div key={type.slug} className="flex min-h-11 items-center gap-3">
|
|
188
|
+
<RadioGroupItem
|
|
189
|
+
id={`${id}-type-${type.slug}`}
|
|
190
|
+
value={type.slug}
|
|
191
|
+
data-testid={`new-schedule-type-${type.slug}`}
|
|
192
|
+
/>
|
|
193
|
+
<Label htmlFor={`${id}-type-${type.slug}`} className="flex flex-wrap gap-x-2">
|
|
194
|
+
{type.title}{" "}
|
|
195
|
+
<Text as="span" variant="caption">
|
|
196
|
+
{labels.duration.replace("{minutes}", String(type.durationMinutes))}
|
|
197
|
+
</Text>
|
|
198
|
+
</Label>
|
|
199
|
+
</div>
|
|
200
|
+
))}
|
|
201
|
+
</RadioGroup>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{slots.kind === "loading" || slots.kind === "error" ? (
|
|
205
|
+
<NewPopupStatus
|
|
206
|
+
failed={slots.kind === "error"}
|
|
207
|
+
labels={labels}
|
|
208
|
+
onRetry={() => loadSlots(slug)}
|
|
209
|
+
/>
|
|
210
|
+
) : null}
|
|
211
|
+
{slots.kind === "ok" && slots.slots.length === 0 ? (
|
|
212
|
+
<Text as="p" variant="body-sm" data-testid="new-schedule-no-slots">
|
|
213
|
+
{labels.noSlots}
|
|
214
|
+
</Text>
|
|
215
|
+
) : null}
|
|
216
|
+
{days.length > 0 ? (
|
|
217
|
+
<div className="flex flex-col gap-2">
|
|
218
|
+
<Text as="p" id={`${id}-time`} variant="body-sm" className="font-semibold">
|
|
219
|
+
{labels.time}
|
|
220
|
+
</Text>
|
|
221
|
+
<RadioGroup aria-labelledby={`${id}-time`} value={start} onValueChange={setStart}>
|
|
222
|
+
{days.map(({ day, slots: daySlots }) => (
|
|
223
|
+
<div key={day} className="flex flex-col gap-1">
|
|
224
|
+
<Text as="p" variant="caption">
|
|
225
|
+
{day}
|
|
226
|
+
</Text>
|
|
227
|
+
{daySlots.map(({ slot, index }) => (
|
|
228
|
+
<div key={slot.start} className="flex min-h-11 items-center gap-3">
|
|
229
|
+
<RadioGroupItem
|
|
230
|
+
id={`${id}-slot-${index}`}
|
|
231
|
+
value={slot.start}
|
|
232
|
+
data-testid={`new-schedule-slot-${index}`}
|
|
233
|
+
/>
|
|
234
|
+
<Label htmlFor={`${id}-slot-${index}`}>
|
|
235
|
+
{timeFormat.format(new Date(slot.start))}
|
|
236
|
+
</Label>
|
|
237
|
+
</div>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
))}
|
|
241
|
+
</RadioGroup>
|
|
242
|
+
</div>
|
|
243
|
+
) : null}
|
|
244
|
+
|
|
245
|
+
<div className="flex flex-col gap-2">
|
|
246
|
+
<div className="flex items-baseline gap-2">
|
|
247
|
+
<Label htmlFor={`${id}-notes`}>{labels.notes}</Label>
|
|
248
|
+
<Text as="span" variant="caption">
|
|
249
|
+
{labels.optional}
|
|
250
|
+
</Text>
|
|
251
|
+
</div>
|
|
252
|
+
<Textarea
|
|
253
|
+
id={`${id}-notes`}
|
|
254
|
+
name="notes"
|
|
255
|
+
rows={3}
|
|
256
|
+
value={notes}
|
|
257
|
+
onChange={(event) => setNotes(event.target.value)}
|
|
258
|
+
data-testid="new-schedule-notes"
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{state.kind === "error" ? <NewPopupRefusal code={state.code} labels={labels} /> : null}
|
|
263
|
+
<div>
|
|
264
|
+
<Button
|
|
265
|
+
type="submit"
|
|
266
|
+
disabled={pending || slug === "" || start === ""}
|
|
267
|
+
// 44px on a phone (a touch-first context); the 36px primary from md up.
|
|
268
|
+
className="max-md:min-h-11"
|
|
269
|
+
data-testid="new-schedule-submit"
|
|
270
|
+
>
|
|
271
|
+
{pending ? labels.submitting : labels.submit}
|
|
272
|
+
</Button>
|
|
273
|
+
</div>
|
|
274
|
+
</form>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client"; // a controlled form posting through the New menu's server action
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SendMessage — the New menu's Send message popup (#8233, spec D9, D10): a
|
|
5
|
+
* subject, a body (line breaks kept) and an optional link, posted to
|
|
6
|
+
* `member.sendMessage` by `sendMessage` (`../new-actions.ts`). No name and no
|
|
7
|
+
* email: the server takes both from the session.
|
|
8
|
+
*
|
|
9
|
+
* Every field is controlled and the form posts what the state holds, so a
|
|
10
|
+
* refusal — a non-https link above all — shows its plain message with every
|
|
11
|
+
* typed value still in place. The link is sent as typed; the server's https
|
|
12
|
+
* rule is the only one. A success closes the popup and refreshes the Inbox.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Button, Input, Label, Text, Textarea } from "@working-theory/ui/primitives";
|
|
16
|
+
import { MESSAGE_BODY_MAX, MESSAGE_SUBJECT_MAX } from "@working-theory/validation";
|
|
17
|
+
import {
|
|
18
|
+
startTransition,
|
|
19
|
+
useActionState,
|
|
20
|
+
useId,
|
|
21
|
+
useState,
|
|
22
|
+
type FormEvent,
|
|
23
|
+
type ReactElement,
|
|
24
|
+
} from "react";
|
|
25
|
+
|
|
26
|
+
import { sendMessage, type NewActionState } from "../new-actions";
|
|
27
|
+
import { NewPopupRefusal, useNewMenuPopup, type NewPopupLabels } from "../new-menu";
|
|
28
|
+
|
|
29
|
+
export interface SendMessageLabels extends NewPopupLabels {
|
|
30
|
+
subject: string;
|
|
31
|
+
body: string;
|
|
32
|
+
link: string;
|
|
33
|
+
linkHelp: string;
|
|
34
|
+
submit: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SendMessageProps {
|
|
38
|
+
locale: string;
|
|
39
|
+
labels: SendMessageLabels;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SendMessage({ labels }: SendMessageProps): ReactElement {
|
|
43
|
+
const popup = useNewMenuPopup();
|
|
44
|
+
const id = useId();
|
|
45
|
+
const [subject, setSubject] = useState("");
|
|
46
|
+
const [body, setBody] = useState("");
|
|
47
|
+
const [link, setLink] = useState("");
|
|
48
|
+
const [state, dispatch, pending] = useActionState<NewActionState, FormData>(
|
|
49
|
+
async (prev, formData) => {
|
|
50
|
+
const next = await sendMessage(prev, formData);
|
|
51
|
+
if (next.kind === "success") popup.close(true);
|
|
52
|
+
return next;
|
|
53
|
+
},
|
|
54
|
+
{ kind: "idle" },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
const formData = new FormData();
|
|
60
|
+
formData.set("subject", subject);
|
|
61
|
+
formData.set("body", body);
|
|
62
|
+
formData.set("link", link);
|
|
63
|
+
startTransition(() => dispatch(formData));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<form onSubmit={onSubmit} className="flex flex-col gap-4" data-testid="new-message-form">
|
|
68
|
+
<div className="flex flex-col gap-2">
|
|
69
|
+
<Label htmlFor={`${id}-subject`}>{labels.subject}</Label>
|
|
70
|
+
<Input
|
|
71
|
+
id={`${id}-subject`}
|
|
72
|
+
name="subject"
|
|
73
|
+
value={subject}
|
|
74
|
+
maxLength={MESSAGE_SUBJECT_MAX}
|
|
75
|
+
required
|
|
76
|
+
onChange={(event) => setSubject(event.target.value)}
|
|
77
|
+
data-testid="new-message-subject"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex flex-col gap-2">
|
|
81
|
+
<Label htmlFor={`${id}-body`}>{labels.body}</Label>
|
|
82
|
+
<Textarea
|
|
83
|
+
id={`${id}-body`}
|
|
84
|
+
name="body"
|
|
85
|
+
value={body}
|
|
86
|
+
rows={5}
|
|
87
|
+
maxLength={MESSAGE_BODY_MAX}
|
|
88
|
+
required
|
|
89
|
+
onChange={(event) => setBody(event.target.value)}
|
|
90
|
+
data-testid="new-message-body"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="flex flex-col gap-2">
|
|
94
|
+
<div className="flex items-baseline gap-2">
|
|
95
|
+
<Label htmlFor={`${id}-link`}>{labels.link}</Label>
|
|
96
|
+
<Text as="span" variant="caption">
|
|
97
|
+
{labels.optional}
|
|
98
|
+
</Text>
|
|
99
|
+
</div>
|
|
100
|
+
<Input
|
|
101
|
+
id={`${id}-link`}
|
|
102
|
+
name="link"
|
|
103
|
+
type="text"
|
|
104
|
+
inputMode="url"
|
|
105
|
+
value={link}
|
|
106
|
+
aria-describedby={`${id}-link-help`}
|
|
107
|
+
onChange={(event) => setLink(event.target.value)}
|
|
108
|
+
data-testid="new-message-link"
|
|
109
|
+
/>
|
|
110
|
+
<Text as="p" id={`${id}-link-help`} variant="caption">
|
|
111
|
+
{labels.linkHelp}
|
|
112
|
+
</Text>
|
|
113
|
+
</div>
|
|
114
|
+
{state.kind === "error" ? <NewPopupRefusal code={state.code} labels={labels} /> : null}
|
|
115
|
+
<div>
|
|
116
|
+
<Button
|
|
117
|
+
type="submit"
|
|
118
|
+
disabled={pending}
|
|
119
|
+
// 44px on a phone (a touch-first context); the 36px primary from md up.
|
|
120
|
+
className="max-md:min-h-11"
|
|
121
|
+
data-testid="new-message-submit"
|
|
122
|
+
>
|
|
123
|
+
{pending ? labels.submitting : labels.submit}
|
|
124
|
+
</Button>
|
|
125
|
+
</div>
|
|
126
|
+
</form>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client"; // reads the URL fragment, which only the browser has
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BillingAnchor — forwards `/account/general#billing` to `/account/billing`
|
|
5
|
+
* (#8232, spec docs/specs/8232-member-settings-account-general-and-spec.md
|
|
6
|
+
* D8, C6).
|
|
7
|
+
*
|
|
8
|
+
* A URL fragment is never sent to the server, so `/account/settings` cannot
|
|
9
|
+
* redirect `#billing` itself: it redirects to `/account/general`, the browser
|
|
10
|
+
* carries the fragment across that redirect, and this part of General sends
|
|
11
|
+
* a `#billing` on to Billing. `#general`, or no fragment, stays on General.
|
|
12
|
+
* It renders nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { localeHref } from "@working-theory/ui";
|
|
16
|
+
import { useRouter } from "next/navigation";
|
|
17
|
+
import { useEffect } from "react";
|
|
18
|
+
|
|
19
|
+
export function BillingAnchor({ locale }: { locale: string }): null {
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (window.location.hash === "#billing") {
|
|
23
|
+
router.replace(localeHref(locale, "/account/billing"));
|
|
24
|
+
}
|
|
25
|
+
}, [locale, router]);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client"; // calls the deletion server action on click and shows the answer in place
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DeletionRequest — User settings › General › Privacy's "Request account
|
|
5
|
+
* deletion" (#8232, spec docs/specs/8232-member-settings-account-general-and-spec.md
|
|
6
|
+
* D5; roadmap D10, #8140 disposition 1).
|
|
7
|
+
*
|
|
8
|
+
* Moved here from `settings/deletion-request.tsx` (#7430) rather than edited
|
|
9
|
+
* in place (C3): `sync-routes` never overwrites a file a scaffold already
|
|
10
|
+
* has, so a synced General importing a changed copy would not compile.
|
|
11
|
+
*
|
|
12
|
+
* Who takes it is `via` (General's `loadProfileSource`, D3):
|
|
13
|
+
*
|
|
14
|
+
* - `member` — the member API answers: Request calls the
|
|
15
|
+
* `requestAccountDeletion` server action (`POST /v1/member/deletion-request`)
|
|
16
|
+
* with an empty body, so the API's own subject and message go to the
|
|
17
|
+
* operator. The API files it with the deployment that holds the account
|
|
18
|
+
* and answers which operator that is; the success line follows that
|
|
19
|
+
* answer, so nothing here tests the topology. An operator completes it —
|
|
20
|
+
* nothing is deleted on click.
|
|
21
|
+
* - `session` — a build with no member API: a pointer to Support, which
|
|
22
|
+
* files into this site's own operator inbox.
|
|
23
|
+
* - `none` — a hub-mode spoke whose hub answers no member route: the
|
|
24
|
+
* unavailable line alone. No Support pointer, because Support on a spoke
|
|
25
|
+
* files into the spoke's own inbox, and a request about a hub account
|
|
26
|
+
* must never land there (SEC-7894-11).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Button, InlineMessage, Link, Text } from "@working-theory/ui/primitives";
|
|
30
|
+
import { useState, useTransition, type ReactElement } from "react";
|
|
31
|
+
|
|
32
|
+
import { requestAccountDeletion, type AccountDeletionResult } from "../profile-actions";
|
|
33
|
+
|
|
34
|
+
export interface DeletionRequestLabels {
|
|
35
|
+
lead: string;
|
|
36
|
+
action: string;
|
|
37
|
+
sentSite: string;
|
|
38
|
+
sentHub: string;
|
|
39
|
+
unverified: string;
|
|
40
|
+
unavailable: string;
|
|
41
|
+
error: string;
|
|
42
|
+
viaSupport: string;
|
|
43
|
+
supportLink: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function DeletionRequest({
|
|
47
|
+
via,
|
|
48
|
+
supportHref,
|
|
49
|
+
labels,
|
|
50
|
+
}: {
|
|
51
|
+
via: "member" | "session" | "none";
|
|
52
|
+
/** This site's own Support page (`/account/support`, localized). */
|
|
53
|
+
supportHref: string;
|
|
54
|
+
labels: DeletionRequestLabels;
|
|
55
|
+
}): ReactElement {
|
|
56
|
+
const [result, setResult] = useState<AccountDeletionResult | null>(null);
|
|
57
|
+
const [pending, startTransition] = useTransition();
|
|
58
|
+
|
|
59
|
+
if (via === "none") {
|
|
60
|
+
return (
|
|
61
|
+
<div data-testid="account-deletion-unavailable">
|
|
62
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
63
|
+
{labels.unavailable}
|
|
64
|
+
</Text>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (via === "session") {
|
|
70
|
+
return (
|
|
71
|
+
<div className="flex flex-col items-start gap-2">
|
|
72
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
73
|
+
{labels.viaSupport}
|
|
74
|
+
</Text>
|
|
75
|
+
<Link
|
|
76
|
+
href={supportHref}
|
|
77
|
+
className="inline-flex min-h-6 items-center"
|
|
78
|
+
data-testid="account-deletion-support"
|
|
79
|
+
>
|
|
80
|
+
{labels.supportLink}
|
|
81
|
+
</Link>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (result?.ok) {
|
|
87
|
+
return (
|
|
88
|
+
<InlineMessage variant="success" role="status" className="text-body-sm">
|
|
89
|
+
{result.sentTo === "hub-operator" ? labels.sentHub : labels.sentSite}
|
|
90
|
+
</InlineMessage>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const error =
|
|
95
|
+
result === null
|
|
96
|
+
? null
|
|
97
|
+
: result.reason === "unverified"
|
|
98
|
+
? labels.unverified
|
|
99
|
+
: result.reason === "unavailable"
|
|
100
|
+
? labels.unavailable
|
|
101
|
+
: labels.error;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex flex-col items-start gap-2">
|
|
105
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
106
|
+
{labels.lead}
|
|
107
|
+
</Text>
|
|
108
|
+
<Button
|
|
109
|
+
type="button"
|
|
110
|
+
variant="outline"
|
|
111
|
+
// #8339 D8 — the Admin's pill Save shape, now that deletion sits on
|
|
112
|
+
// the shared General composition's pane.
|
|
113
|
+
className="!h-11 md:!h-10 !rounded-full !px-5"
|
|
114
|
+
disabled={pending}
|
|
115
|
+
onClick={() => {
|
|
116
|
+
startTransition(async () => {
|
|
117
|
+
try {
|
|
118
|
+
setResult(await requestAccountDeletion());
|
|
119
|
+
} catch {
|
|
120
|
+
// A network failure throws rather than answering a reason.
|
|
121
|
+
setResult({ ok: false, reason: "failed" });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}}
|
|
125
|
+
data-testid="account-deletion-request"
|
|
126
|
+
>
|
|
127
|
+
{labels.action}
|
|
128
|
+
</Button>
|
|
129
|
+
{error !== null ? <InlineMessage variant="error">{error}</InlineMessage> : null}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|