create-ailk 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  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/lib/env-catalog.js +5 -9
  9. package/dist/lib/license-key-files.d.ts +6 -3
  10. package/dist/lib/license-key-files.js +8 -2
  11. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  12. package/dist/lib/scaffold-render-sweep.js +24 -3
  13. package/dist/license-set.d.ts +1 -1
  14. package/dist/license-set.js +13 -8
  15. package/dist/module-architecture.js +119 -0
  16. package/dist/programmatic.js +3 -0
  17. package/dist/spoke-checkout.d.ts +11 -3
  18. package/dist/spoke-checkout.js +62 -12
  19. package/dist/starter-grid.d.ts +1 -1
  20. package/dist/surfaces.js +140 -1
  21. package/dist/template-manifest.d.ts +4 -3
  22. package/dist/topology.d.ts +15 -2
  23. package/dist/topology.js +83 -4
  24. package/package.json +5 -4
  25. package/templates/.env.example +3 -3
  26. package/templates/.github/workflows/ci.yml +5 -0
  27. package/templates/apps/api/CLAUDE.md +25 -7
  28. package/templates/apps/api/LICENSE +6 -5
  29. package/templates/apps/api/NOTICE +2 -0
  30. package/templates/apps/api/jest.config.cjs +4 -29
  31. package/templates/apps/api/package.json +1 -0
  32. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  33. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  34. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  35. package/templates/apps/api/src/lib/stripe.ts +81 -0
  36. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  37. package/templates/apps/api/src/middleware/auth.ts +206 -46
  38. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  39. package/templates/apps/api/src/server.ts +107 -0
  40. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  41. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  42. package/templates/apps/api/vercel.json +2 -2
  43. package/templates/apps/mcp/.env.example +10 -0
  44. package/templates/apps/mcp/CLAUDE.md +58 -7
  45. package/templates/apps/mcp/LICENSE +6 -5
  46. package/templates/apps/mcp/NOTICE +2 -0
  47. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  48. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  49. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  50. package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
  51. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  52. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  53. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  54. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  55. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  56. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  57. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  58. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  59. package/templates/apps/mcp/jest.config.cjs +3 -44
  60. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  61. package/templates/apps/mcp/src/app.ts +32 -0
  62. package/templates/apps/mcp/src/boot.ts +167 -0
  63. package/templates/apps/mcp/src/http-transport.ts +132 -88
  64. package/templates/apps/mcp/src/index.ts +19 -126
  65. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  66. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  67. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  68. package/templates/apps/mcp/src/request-context.ts +52 -0
  69. package/templates/apps/mcp/src/server.ts +10 -2
  70. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  71. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  72. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  73. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  74. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  75. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  76. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  77. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  78. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  79. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  80. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  81. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  82. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  83. package/templates/apps/mcp/tsconfig.json +2 -0
  84. package/templates/apps/web/LICENSE +6 -5
  85. package/templates/apps/web/NOTICE +2 -0
  86. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  87. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  88. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  92. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  94. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  98. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  102. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  104. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
  105. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  125. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  126. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  127. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  132. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  135. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  138. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  139. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  140. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  141. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  142. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  143. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  144. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  145. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  146. package/templates/apps/web/auth.ts +5 -1
  147. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  148. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  149. package/templates/apps/web/lib/account-config.ts +188 -52
  150. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  151. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  152. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  153. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  154. package/templates/apps/web/lib/site-brand.tsx +22 -6
  155. package/templates/apps/web/lib/topology.generated.ts +27 -0
  156. package/templates/apps/web/messages/en.json +156 -35
  157. package/templates/database/CHANGELOG.md +21 -0
  158. package/templates/database/LICENSE +6 -5
  159. package/templates/database/NOTICE +2 -0
  160. package/templates/database/README.md +1 -1
  161. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  162. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  163. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  164. package/templates/database/__tests__/seed.test.ts +54 -2
  165. package/templates/database/inbox/schema.prisma +90 -15
  166. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  167. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  168. package/templates/database/package.json +3 -2
  169. package/templates/database/seed.ts +34 -2
  170. package/versions.json +36 -36
  171. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  172. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  173. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  174. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  175. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  176. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  177. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  178. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  179. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  180. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  182. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  183. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  184. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  185. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  186. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  187. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  188. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  189. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  190. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  191. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  192. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  193. 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,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
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * /[locale]/account/general — User settings › General (#8339 D8; builds on
3
+ * #8232 D2).
4
+ *
5
+ * RSC, inside the account layout's SignedInShell with the rail visible, in
6
+ * the member's four-tab settings frame (D7). Renders the shared
7
+ * `AccountSettings` composition (@working-theory/templates/account, spec
8
+ * 8339 D4) — the same General form the Admin renders — through the client
9
+ * adapter `GeneralSettings` (`profile-form.tsx`). Below the form:
10
+ *
11
+ * - Request account deletion (`DeletionRequest`, #8232 D5);
12
+ * - any section a module contributes (`accountSettingsSections`);
13
+ * - `BillingAnchor`, which forwards a `#billing` fragment to Billing.
14
+ *
15
+ * Sign-in moved to Security and cookie settings to Notifications (#8339
16
+ * C6) — General no longer renders either.
17
+ *
18
+ * Whether the profile and deletion go through the member API is
19
+ * `loadProfileSource` (D3), never a topology test here.
20
+ */
21
+
22
+ import { AccountSettingsFrame, AccountSettingsPane } from "@working-theory/templates/account";
23
+ import { localeHref } from "@working-theory/ui";
24
+ import { Separator } from "@working-theory/ui/primitives";
25
+ import type { Metadata } from "next";
26
+ import { getTranslations } from "next-intl/server";
27
+ import type { ReactElement } from "react";
28
+
29
+ import { accountSettingsSections } from "../../../../../lib/account-config";
30
+ import { accountSession } from "../account-data";
31
+ import { siteThemeModeCount } from "../settings/theme-modes";
32
+ import { memberSettingsFrame, SettingsSection, type Translate } from "../settings-frame";
33
+
34
+ import { BillingAnchor } from "./billing-anchor";
35
+ import { DeletionRequest } from "./deletion-request";
36
+ import { GeneralSettings } from "./profile-form";
37
+ import { loadProfileSource } from "./profile-source";
38
+
39
+ interface Props {
40
+ params: Promise<{ locale: string }>;
41
+ }
42
+
43
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
44
+ const { locale } = await params;
45
+ const t = await getTranslations({ locale, namespace: "account" });
46
+ return { title: t("settings.general.title"), description: t("settings.general.lead") };
47
+ }
48
+
49
+ export default async function AccountGeneralPage({ params }: Props): Promise<ReactElement> {
50
+ const { locale } = await params;
51
+ const t = await getTranslations({ locale, namespace: "account" });
52
+ const session = await accountSession(locale);
53
+
54
+ // Unreachable behind the (authed) gate; a session that expired between
55
+ // the layout and the page renders the pane's title alone.
56
+ if (!session) {
57
+ return (
58
+ <AccountSettingsFrame {...memberSettingsFrame(locale, t as unknown as Translate, "general")} name="">
59
+ <AccountSettingsPane
60
+ id="account-settings-general"
61
+ title={t("settings.general.title")}
62
+ lead={t("settings.general.lead")}
63
+ >
64
+ {null}
65
+ </AccountSettingsPane>
66
+ </AccountSettingsFrame>
67
+ );
68
+ }
69
+
70
+ const { ctx } = session;
71
+ const source = await loadProfileSource(ctx);
72
+
73
+ return (
74
+ <GeneralSettings
75
+ frame={memberSettingsFrame(locale, t as unknown as Translate, "general")}
76
+ user={{ name: ctx.user.name ?? "", email: ctx.user.email, image: source.image }}
77
+ labels={{
78
+ title: t("settings.general.title"),
79
+ lead: t("settings.general.lead"),
80
+ colorMode: t("settings.general.colorMode"),
81
+ light: t("settings.general.light"),
82
+ auto: t("settings.general.auto"),
83
+ dark: t("settings.general.dark"),
84
+ emailLabel: t("settings.general.email"),
85
+ nameLabel: t("settings.general.name"),
86
+ photoLabel: t("settings.general.photo"),
87
+ photoHint: t("settings.general.photoHint"),
88
+ photoPlaceholder: t("settings.general.photoPlaceholder"),
89
+ saveButton: t("settings.general.save"),
90
+ saving: t("settings.general.saving"),
91
+ saved: t("settings.general.saved"),
92
+ saveError: t("settings.general.saveError"),
93
+ nameRequired: t("settings.general.nameRequired"),
94
+ photoInvalid: t("settings.general.photoInvalid"),
95
+ unverified: t("settings.general.unverified"),
96
+ unavailable: t("settings.general.unavailable"),
97
+ }}
98
+ colorMode={
99
+ siteThemeModeCount() > 1
100
+ ? { cookieName: process.env.NEXT_PUBLIC_THEME_COOKIE_NAME ?? "ailk-theme" }
101
+ : null
102
+ }
103
+ via={source.via}
104
+ >
105
+ <Separator />
106
+ <div data-testid="account-deletion">
107
+ <DeletionRequest
108
+ via={source.via}
109
+ supportHref={localeHref(locale, "/account/support")}
110
+ labels={{
111
+ lead: t("settings.privacy.deletionLead"),
112
+ action: t("settings.privacy.deletion"),
113
+ sentSite: t("settings.privacy.deletionSentSite"),
114
+ sentHub: t("settings.privacy.deletionSentHub"),
115
+ unverified: t("settings.privacy.deletionUnverified"),
116
+ unavailable: t("settings.privacy.deletionUnavailable"),
117
+ error: t("settings.privacy.deletionError"),
118
+ viaSupport: t("settings.privacy.deletionViaSupport"),
119
+ supportLink: t("settings.privacy.deletionSupportLink"),
120
+ }}
121
+ />
122
+ </div>
123
+ {accountSettingsSections.map((section) => (
124
+ <SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
125
+ {section.render(ctx)}
126
+ </SettingsSection>
127
+ ))}
128
+ <BillingAnchor locale={locale} />
129
+ </GeneralSettings>
130
+ );
131
+ }