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.
Files changed (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. 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
+ }