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,219 @@
1
+ "use server";
2
+
3
+ /**
4
+ * account/new-actions.ts — the New menu's reads and writes (#8233, spec
5
+ * docs/specs/8233-member-new-menu-the-new-spec.md D5, D6; the writes are
6
+ * #8170's).
7
+ *
8
+ * A popup reads its data when it opens (D5) and posts through a write here:
9
+ *
10
+ * - `readMeetingTypes` — `member.meetingTypes`, the types the provider
11
+ * offers its clients.
12
+ * - `readSlots` — `member.availability` for one type, from now to
13
+ * `SLOT_WINDOW_DAYS` later. The range is computed here; the browser
14
+ * sends only the slug and its time zone.
15
+ * - `readRequestForm` — `member.requestForm`, the request-quote magnet's
16
+ * current steps, or `form: null` while Request quote is switched off.
17
+ * - `sendMessage` · `requestQuote` · `bookService` — `member.sendMessage`,
18
+ * `member.requestQuote` and `member.book`.
19
+ *
20
+ * Every call sends `forwardedCookieInit()`'s init as built: on a single site
21
+ * the session cookie plus the request's `Origin` (`apps/api`'s cookie CSRF
22
+ * check), on a hub-mode spoke the hub credential — so nothing here tests the
23
+ * topology, and no identity is posted: the server takes the name and the
24
+ * verified email from the credential (8170 D7).
25
+ *
26
+ * A value is sent exactly as typed — never trimmed, never given a scheme: the
27
+ * server's `plainText` and `isHttpsLink` are the one rule. An optional value
28
+ * left empty or all-space is omitted, because the server refuses `""`. A
29
+ * refusal maps to its `error.code` when a popup has words for it, else to
30
+ * `failed`.
31
+ *
32
+ * Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
33
+ * parity test reads that suffix, and these member writes have no MCP twin by
34
+ * decision (8170's header). Logs carry the function, the status and the code
35
+ * only — never a typed value or a credential.
36
+ */
37
+
38
+ import { member } from "@working-theory/api-client";
39
+ import type {
40
+ MemberMeetingType,
41
+ MemberRequestFormResponse,
42
+ SchedulingSlot,
43
+ } from "@working-theory/validation";
44
+
45
+ import { forwardedCookieInit } from "../../../../lib/server-api";
46
+
47
+ /** How far ahead Schedule service offers times (the read's own cap is 60 days). */
48
+ const SLOT_WINDOW_DAYS = 14;
49
+ const DAY_MS = 24 * 60 * 60 * 1000;
50
+
51
+ /** The refusal codes a popup has its own words for; anything else is `failed`. */
52
+ const KNOWN_CODES = [
53
+ "link_not_https",
54
+ "answers_invalid",
55
+ "request_form_off",
56
+ "meeting_type_not_offered",
57
+ "slot_unavailable",
58
+ "too_many_requests",
59
+ "invalid_input",
60
+ ] as const;
61
+
62
+ export type NewActionErrorCode = (typeof KNOWN_CODES)[number] | "failed";
63
+
64
+ /** What a popup's form shows after a submit. */
65
+ export type NewActionState =
66
+ | { kind: "idle" }
67
+ | { kind: "success" }
68
+ | { kind: "error"; code: NewActionErrorCode };
69
+
70
+ export type NewReadResult<T> = ({ kind: "ok" } & T) | { kind: "error" };
71
+
72
+ /** Structural mirror of `@working-theory/api-client`'s `ApiResult<T>` (not re-exported). */
73
+ type ApiResult<T> = { ok: true; body: T } | { ok: false; status: number; body: unknown };
74
+
75
+ /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
76
+ function errorName(err: unknown): string {
77
+ return err instanceof Error ? err.name : "unknown";
78
+ }
79
+
80
+ /** The `error.code` of a member refusal body, when it has one. */
81
+ function errorCode(body: unknown): string | null {
82
+ if (typeof body !== "object" || body === null) return null;
83
+ const error = (body as { error?: unknown }).error;
84
+ if (typeof error !== "object" || error === null) return null;
85
+ const code = (error as { code?: unknown }).code;
86
+ return typeof code === "string" ? code : null;
87
+ }
88
+
89
+ function knownCode(code: string | null): NewActionErrorCode {
90
+ return KNOWN_CODES.find((known) => known === code) ?? "failed";
91
+ }
92
+
93
+ /** A posted value as typed; `undefined` when it is absent or not a string. */
94
+ function value(formData: FormData, name: string): string | undefined {
95
+ const entry = formData.get(name);
96
+ return typeof entry === "string" ? entry : undefined;
97
+ }
98
+
99
+ /** An optional posted value as typed, or `undefined` when it is empty or all-space. */
100
+ function optional(formData: FormData, name: string): string | undefined {
101
+ const entry = value(formData, name);
102
+ return entry !== undefined && entry.trim() !== "" ? entry : undefined;
103
+ }
104
+
105
+ async function runRead<T, R>(
106
+ name: string,
107
+ call: (init: { headers: Record<string, string> }) => Promise<ApiResult<T>>,
108
+ shape: (body: T) => R,
109
+ ): Promise<NewReadResult<R>> {
110
+ try {
111
+ const res = await call(await forwardedCookieInit());
112
+ if (res.ok) return { kind: "ok", ...shape(res.body) };
113
+ console.error(`[new-actions] ${name} status=${res.status} code=${errorCode(res.body) ?? "none"}`);
114
+ } catch (err) {
115
+ console.error(`[new-actions] ${name} errName=${errorName(err)}`);
116
+ }
117
+ return { kind: "error" };
118
+ }
119
+
120
+ /** Action input is untrusted: a hand-built call may post anything but FormData. */
121
+ function isFormData(input: unknown): input is FormData {
122
+ return input instanceof FormData;
123
+ }
124
+
125
+ async function runWrite(
126
+ name: string,
127
+ call: (init: { headers: Record<string, string> }) => Promise<ApiResult<unknown>>,
128
+ ): Promise<NewActionState> {
129
+ try {
130
+ const res = await call(await forwardedCookieInit());
131
+ if (res.ok) return { kind: "success" };
132
+ const code = knownCode(errorCode(res.body));
133
+ console.error(`[new-actions] ${name} status=${res.status} code=${code}`);
134
+ return { kind: "error", code };
135
+ } catch (err) {
136
+ console.error(`[new-actions] ${name} errName=${errorName(err)}`);
137
+ return { kind: "error", code: "failed" };
138
+ }
139
+ }
140
+
141
+ // ── Reads (D5) ───────────────────────────────────────────────────────────────
142
+
143
+ export async function readMeetingTypes(): Promise<NewReadResult<{ items: MemberMeetingType[] }>> {
144
+ return runRead("readMeetingTypes", (init) => member.meetingTypes(init), ({ items }) => ({ items }));
145
+ }
146
+
147
+ export async function readSlots(input: {
148
+ slug: string;
149
+ timeZone?: string;
150
+ }): Promise<NewReadResult<{ slots: SchedulingSlot[] }>> {
151
+ // Action input is untrusted: a hand-built call may send anything.
152
+ const { slug, timeZone } = (typeof input === "object" && input !== null ? input : {}) as {
153
+ slug?: unknown;
154
+ timeZone?: unknown;
155
+ };
156
+ if (typeof slug !== "string") return { kind: "error" };
157
+ const from = new Date();
158
+ const to = new Date(from.getTime() + SLOT_WINDOW_DAYS * DAY_MS);
159
+ const query = {
160
+ slug,
161
+ from: from.toISOString(),
162
+ to: to.toISOString(),
163
+ ...(typeof timeZone === "string" && timeZone !== "" ? { timeZone } : {}),
164
+ };
165
+ return runRead("readSlots", (init) => member.availability(query, init), ({ slots }) => ({ slots }));
166
+ }
167
+
168
+ export async function readRequestForm(): Promise<
169
+ NewReadResult<{ form: MemberRequestFormResponse["form"] }>
170
+ > {
171
+ return runRead("readRequestForm", (init) => member.requestForm(init), ({ form }) => ({ form }));
172
+ }
173
+
174
+ // ── Writes (D6) — `(prev, formData)` for `useActionState` ───────────────────
175
+
176
+ export async function sendMessage(
177
+ _prev: NewActionState,
178
+ formData: FormData,
179
+ ): Promise<NewActionState> {
180
+ if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
181
+ const link = optional(formData, "link");
182
+ const body = {
183
+ subject: value(formData, "subject") ?? "",
184
+ body: value(formData, "body") ?? "",
185
+ ...(link === undefined ? {} : { link }),
186
+ };
187
+ return runWrite("sendMessage", (init) => member.sendMessage(body, init));
188
+ }
189
+
190
+ /** The answers are the entries named `answer.<field name>`; a blank one is omitted. */
191
+ export async function requestQuote(
192
+ _prev: NewActionState,
193
+ formData: FormData,
194
+ ): Promise<NewActionState> {
195
+ if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
196
+ const answers: Record<string, string> = {};
197
+ for (const key of new Set(formData.keys())) {
198
+ if (!key.startsWith("answer.")) continue;
199
+ const answer = optional(formData, key);
200
+ if (answer !== undefined) answers[key.slice("answer.".length)] = answer;
201
+ }
202
+ return runWrite("requestQuote", (init) => member.requestQuote({ answers }, init));
203
+ }
204
+
205
+ export async function bookService(
206
+ _prev: NewActionState,
207
+ formData: FormData,
208
+ ): Promise<NewActionState> {
209
+ if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
210
+ const timeZone = optional(formData, "timeZone");
211
+ const notes = optional(formData, "notes");
212
+ const body = {
213
+ slug: value(formData, "slug") ?? "",
214
+ start: value(formData, "start") ?? "",
215
+ ...(timeZone === undefined ? {} : { timeZone }),
216
+ ...(notes === undefined ? {} : { notes }),
217
+ };
218
+ return runWrite("bookService", (init) => member.book(body, init));
219
+ }
@@ -0,0 +1,340 @@
1
+ "use client"; // the menu, the dialog and the top-bar portal hold client state
2
+
3
+ /**
4
+ * NewMenu — the member area's New button over the Inbox (#8233, spec
5
+ * docs/specs/8233-member-new-menu-the-new-spec.md D3, D5, D10; brief Layout
6
+ * item 3). Copied from the Admin's NewMenu idiom (apps do not import apps).
7
+ *
8
+ * Its rows are the contributed New actions the Inbox page found offered
9
+ * (`accountNewActions`, each already gated by its own member read). A row is
10
+ * a menu item, never a link: it opens that action's popup in one dialog over
11
+ * the Inbox. With no action offered there is no menu at all.
12
+ *
13
+ * Two triggers share the dialog: the desktop New in the page's header row
14
+ * (`md` up), and a compact + the frame's phone top bar holds. The frame hands
15
+ * the top-bar node down through `NewMenuSlotProvider` on the Inbox route
16
+ * only; outside a frame (a test, a story) the node is null and only the
17
+ * desktop trigger renders.
18
+ *
19
+ * A popup body talks back through `useNewMenuPopup()`: `close(true)` after a
20
+ * successful write closes the dialog and refreshes the Inbox, `open(id)`
21
+ * switches to another offered action. The two popups that read on open share
22
+ * `useOpenRead` and `NewPopupStatus`, so loading, a failed read and its retry
23
+ * look the same in both (D5).
24
+ */
25
+
26
+ import {
27
+ Button,
28
+ Dialog,
29
+ DialogContent,
30
+ DialogHeader,
31
+ DialogTitle,
32
+ DropdownMenu,
33
+ DropdownMenuContent,
34
+ DropdownMenuItem,
35
+ DropdownMenuTrigger,
36
+ Icon,
37
+ InlineMessage,
38
+ Text,
39
+ } from "@working-theory/ui/primitives";
40
+ import { ChevronDown, Plus } from "lucide-react";
41
+ import { useRouter } from "next/navigation";
42
+ import {
43
+ createContext,
44
+ useCallback,
45
+ useContext,
46
+ useEffect,
47
+ useMemo,
48
+ useRef,
49
+ useState,
50
+ type ReactElement,
51
+ type ReactNode,
52
+ } from "react";
53
+ import { createPortal } from "react-dom";
54
+
55
+ import type { NewActionErrorCode } from "./new-actions";
56
+
57
+ /** One offered action, its label already translated. */
58
+ export interface NewMenuAction {
59
+ id: string;
60
+ label: string;
61
+ popup: ReactNode;
62
+ }
63
+
64
+ export interface NewMenuLabels {
65
+ trigger: string;
66
+ close: string;
67
+ }
68
+
69
+ /** The strings every popup shares (D10, D11). */
70
+ export interface NewPopupLabels {
71
+ loading: string;
72
+ loadFailed: string;
73
+ retry: string;
74
+ submitting: string;
75
+ optional: string;
76
+ errors: Record<NewActionErrorCode, string>;
77
+ }
78
+
79
+ // ── The top-bar slot ─────────────────────────────────────────────────────────
80
+
81
+ const NewMenuSlotContext = createContext<HTMLElement | null>(null);
82
+
83
+ /** Hands the phone top bar's trailing node to the page; `null` where the bar has none. */
84
+ export function NewMenuSlotProvider({
85
+ node,
86
+ children,
87
+ }: {
88
+ node: HTMLElement | null;
89
+ children: ReactNode;
90
+ }): ReactElement {
91
+ return <NewMenuSlotContext.Provider value={node}>{children}</NewMenuSlotContext.Provider>;
92
+ }
93
+
94
+ export function useNewMenuSlot(): HTMLElement | null {
95
+ return useContext(NewMenuSlotContext);
96
+ }
97
+
98
+ // ── The popup API ────────────────────────────────────────────────────────────
99
+
100
+ export interface NewMenuPopupApi {
101
+ /** Closes the dialog; after a successful write, also refreshes the Inbox. */
102
+ close(submitted: boolean): void;
103
+ /** Switches the dialog to another offered action. */
104
+ open(id: string): void;
105
+ /** Whether an action is offered. */
106
+ has(id: string): boolean;
107
+ }
108
+
109
+ const INERT_POPUP_API: NewMenuPopupApi = {
110
+ close: () => undefined,
111
+ open: () => undefined,
112
+ has: () => false,
113
+ };
114
+
115
+ const NewMenuPopupContext = createContext<NewMenuPopupApi>(INERT_POPUP_API);
116
+
117
+ export function useNewMenuPopup(): NewMenuPopupApi {
118
+ return useContext(NewMenuPopupContext);
119
+ }
120
+
121
+ // ── The menu ─────────────────────────────────────────────────────────────────
122
+
123
+ function ActionMenu({
124
+ compact,
125
+ actions,
126
+ label,
127
+ onPick,
128
+ }: {
129
+ compact: boolean;
130
+ actions: readonly NewMenuAction[];
131
+ label: string;
132
+ onPick: (id: string, trigger: HTMLElement | null) => void;
133
+ }): ReactElement {
134
+ const triggerRef = useRef<HTMLButtonElement>(null);
135
+ // A pick hands focus to the dialog, so the closing menu must not take it back.
136
+ const picked = useRef(false);
137
+ return (
138
+ <DropdownMenu>
139
+ <DropdownMenuTrigger asChild>
140
+ {compact ? (
141
+ <Button
142
+ ref={triggerRef}
143
+ variant="ghost"
144
+ size="big"
145
+ iconOnly
146
+ aria-label={label}
147
+ className="size-11 rounded-full"
148
+ data-testid="account-new-menu-trigger-compact"
149
+ >
150
+ <Icon icon={Plus} size="md" decorative />
151
+ </Button>
152
+ ) : (
153
+ <Button
154
+ ref={triggerRef}
155
+ variant="default"
156
+ className="hidden gap-1 md:inline-flex"
157
+ data-testid="account-new-menu-trigger"
158
+ >
159
+ <Icon icon={Plus} size="sm" decorative />
160
+ {label}
161
+ <Icon icon={ChevronDown} size="sm" decorative />
162
+ </Button>
163
+ )}
164
+ </DropdownMenuTrigger>
165
+ <DropdownMenuContent
166
+ align="end"
167
+ className="min-w-56"
168
+ onCloseAutoFocus={(event) => {
169
+ if (picked.current) event.preventDefault();
170
+ picked.current = false;
171
+ }}
172
+ data-testid="account-new-menu-content"
173
+ >
174
+ {actions.map((action) => (
175
+ <DropdownMenuItem
176
+ key={action.id}
177
+ onSelect={() => {
178
+ picked.current = true;
179
+ onPick(action.id, triggerRef.current);
180
+ }}
181
+ data-testid={`account-new-menu-item-${action.id}`}
182
+ >
183
+ {action.label}
184
+ </DropdownMenuItem>
185
+ ))}
186
+ </DropdownMenuContent>
187
+ </DropdownMenu>
188
+ );
189
+ }
190
+
191
+ export function NewMenu({
192
+ actions,
193
+ labels,
194
+ }: {
195
+ actions: readonly NewMenuAction[];
196
+ labels: NewMenuLabels;
197
+ }): ReactElement | null {
198
+ const router = useRouter();
199
+ const slot = useNewMenuSlot();
200
+ const [openId, setOpenId] = useState<string | null>(null);
201
+ // The trigger that opened the dialog gets focus back when it closes.
202
+ const returnFocus = useRef<HTMLElement | null>(null);
203
+
204
+ const api = useMemo<NewMenuPopupApi>(() => {
205
+ const has = (id: string): boolean => actions.some((action) => action.id === id);
206
+ return {
207
+ close(submitted) {
208
+ setOpenId(null);
209
+ if (submitted) router.refresh();
210
+ },
211
+ open(id) {
212
+ if (has(id)) setOpenId(id);
213
+ },
214
+ has,
215
+ };
216
+ }, [actions, router]);
217
+
218
+ if (actions.length === 0) return null;
219
+
220
+ const current = actions.find((action) => action.id === openId) ?? null;
221
+ const onPick = (id: string, trigger: HTMLElement | null): void => {
222
+ returnFocus.current = trigger;
223
+ setOpenId(id);
224
+ };
225
+
226
+ return (
227
+ <>
228
+ <ActionMenu compact={false} actions={actions} label={labels.trigger} onPick={onPick} />
229
+ {slot
230
+ ? createPortal(
231
+ <ActionMenu compact actions={actions} label={labels.trigger} onPick={onPick} />,
232
+ slot,
233
+ )
234
+ : null}
235
+ <Dialog
236
+ open={current !== null}
237
+ onOpenChange={(open) => {
238
+ if (!open) setOpenId(null);
239
+ }}
240
+ >
241
+ {current ? (
242
+ <DialogContent
243
+ closeLabel={labels.close}
244
+ aria-describedby={undefined}
245
+ onCloseAutoFocus={(event) => {
246
+ const trigger = returnFocus.current;
247
+ if (trigger?.isConnected) {
248
+ event.preventDefault();
249
+ trigger.focus();
250
+ }
251
+ }}
252
+ data-testid="account-new-popup"
253
+ >
254
+ <DialogHeader>
255
+ <DialogTitle>{current.label}</DialogTitle>
256
+ </DialogHeader>
257
+ <NewMenuPopupContext.Provider value={api}>
258
+ {/* Keyed, so switching actions mounts a fresh popup that reads anew. */}
259
+ <div key={current.id} className="mt-block-tight">
260
+ {current.popup}
261
+ </div>
262
+ </NewMenuPopupContext.Provider>
263
+ </DialogContent>
264
+ ) : null}
265
+ </Dialog>
266
+ </>
267
+ );
268
+ }
269
+
270
+ // ── Shared popup parts (D5, D10) ─────────────────────────────────────────────
271
+
272
+ export type OpenRead<T> = "loading" | { kind: "error" } | ({ kind: "ok" } & T);
273
+
274
+ /**
275
+ * Reads a popup's data when it mounts — the dialog mounts a popup only while
276
+ * it is open, so this is a read per open (D5) — and returns a retry.
277
+ */
278
+ export function useOpenRead<T>(
279
+ read: () => Promise<{ kind: "error" } | ({ kind: "ok" } & T)>,
280
+ ): [OpenRead<T>, () => void] {
281
+ const [state, setState] = useState<OpenRead<T>>("loading");
282
+ // Only the newest read lands: a retry never loses to the read it replaced.
283
+ const latest = useRef(0);
284
+ const load = useCallback(() => {
285
+ const readId = ++latest.current;
286
+ const land = (next: OpenRead<T>): void => {
287
+ if (readId === latest.current) setState(next);
288
+ };
289
+ setState("loading");
290
+ read().then(land, () => land({ kind: "error" }));
291
+ }, [read]);
292
+ useEffect(load, [load]);
293
+ return [state, load];
294
+ }
295
+
296
+ /** A popup's loading line, or its failed-read line with Try again. */
297
+ export function NewPopupStatus({
298
+ failed,
299
+ labels,
300
+ onRetry,
301
+ }: {
302
+ failed: boolean;
303
+ labels: NewPopupLabels;
304
+ onRetry: () => void;
305
+ }): ReactElement {
306
+ if (!failed) {
307
+ return (
308
+ <div role="status" data-testid="new-popup-loading">
309
+ <Text as="p" variant="body-sm" tone="muted">
310
+ {labels.loading}
311
+ </Text>
312
+ </div>
313
+ );
314
+ }
315
+ return (
316
+ <div className="flex flex-wrap items-center gap-3" data-testid="new-popup-load-failed">
317
+ <InlineMessage variant="error" className="text-body-sm">
318
+ {labels.loadFailed}
319
+ </InlineMessage>
320
+ <Button variant="outline" onClick={onRetry} data-testid="new-popup-retry">
321
+ {labels.retry}
322
+ </Button>
323
+ </div>
324
+ );
325
+ }
326
+
327
+ /** A refused write's plain message; the popup stays open with every value kept. */
328
+ export function NewPopupRefusal({
329
+ code,
330
+ labels,
331
+ }: {
332
+ code: NewActionErrorCode;
333
+ labels: NewPopupLabels;
334
+ }): ReactElement {
335
+ return (
336
+ <InlineMessage variant="error" data-testid="new-popup-error">
337
+ {labels.errors[code]}
338
+ </InlineMessage>
339
+ );
340
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * new-popups.tsx — the New menu's actions with their popups bound (#8233 D1,
3
+ * D2, D6–D8).
4
+ *
5
+ * `lib/account-config.ts` carries each action's server half — its id, its
6
+ * label and the member read that decides whether it is offered — and this
7
+ * file adds the popup each one opens, composing the `AccountNewAction`
8
+ * contract (`@working-theory/templates/account`) the Inbox page consumes.
9
+ *
10
+ * The split is create-ailk's spoke closure (`spokeExclusions`,
11
+ * `packages/create-ailk/src/topology.ts`): a spoke without sign-in drops the
12
+ * whole `(authed)` group, these popups included, and every file that imports
13
+ * one of them goes too — but `account-config.ts` is a shared lib file every
14
+ * spoke keeps, so it may import nothing under `(authed)` (the closure guard
15
+ * refuses the scaffold, naming it). This file sits inside the group: it goes
16
+ * with the popups on such a spoke, and stays with them on a hub-sign-in one
17
+ * and on a single site, where the page still renders all three.
18
+ *
19
+ * A popup receives only its locale and strings built through `ctx.t` — never
20
+ * `ctx`, its init, its user or `t` itself — so no credential or email reaches
21
+ * the client payload (SEC-8233-1). `popups` is keyed on the config's own id
22
+ * union, so a read added there without a popup here is a type error, never a
23
+ * menu row that opens nothing.
24
+ */
25
+
26
+ import type {
27
+ AccountContext,
28
+ AccountNewAction,
29
+ AccountTranslate,
30
+ } from "@working-theory/templates/account";
31
+ import type { ReactNode } from "react";
32
+
33
+ import {
34
+ accountNewActions,
35
+ type AccountNewActionId,
36
+ } from "../../../../lib/account-config";
37
+
38
+ import { RequestQuote } from "./components/request-quote";
39
+ import { ScheduleService } from "./components/schedule-service";
40
+ import { SendMessage } from "./components/send-message";
41
+ import type { NewPopupLabels } from "./new-menu";
42
+
43
+ /** The strings every popup shares — plain strings, so nothing else crosses to the client. */
44
+ function popupLabels(t: AccountTranslate): NewPopupLabels {
45
+ return {
46
+ loading: t("new.loading"),
47
+ loadFailed: t("new.loadFailed"),
48
+ retry: t("new.retry"),
49
+ submitting: t("new.submitting"),
50
+ optional: t("new.optional"),
51
+ errors: {
52
+ link_not_https: t("new.errors.linkNotHttps"),
53
+ answers_invalid: t("new.errors.answersInvalid"),
54
+ request_form_off: t("new.errors.requestFormOff"),
55
+ meeting_type_not_offered: t("new.errors.meetingTypeNotOffered"),
56
+ slot_unavailable: t("new.errors.slotUnavailable"),
57
+ too_many_requests: t("new.errors.tooManyRequests"),
58
+ invalid_input: t("new.errors.invalidInput"),
59
+ failed: t("new.errors.failed"),
60
+ },
61
+ };
62
+ }
63
+
64
+ /** The popup each action opens, keyed on the config's ids (D6–D8). */
65
+ const popups = {
66
+ "schedule-service": ({ locale, t }) => (
67
+ <ScheduleService
68
+ locale={locale}
69
+ labels={{
70
+ ...popupLabels(t),
71
+ service: t("new.schedule.service"),
72
+ // The popup fills each type's own minutes into the literal placeholder.
73
+ duration: t("new.schedule.duration", { minutes: "{minutes}" }),
74
+ time: t("new.schedule.time"),
75
+ notes: t("new.schedule.notes"),
76
+ submit: t("new.schedule.submit"),
77
+ none: t("new.schedule.none"),
78
+ suggest: t("new.schedule.suggest"),
79
+ noSlots: t("new.schedule.noSlots"),
80
+ }}
81
+ />
82
+ ),
83
+ "request-quote": ({ locale, t }) => (
84
+ <RequestQuote locale={locale} labels={{ ...popupLabels(t), submit: t("new.quote.submit") }} />
85
+ ),
86
+ "send-message": ({ locale, t }) => (
87
+ <SendMessage
88
+ locale={locale}
89
+ labels={{
90
+ ...popupLabels(t),
91
+ subject: t("new.message.subject"),
92
+ body: t("new.message.body"),
93
+ link: t("new.message.link"),
94
+ linkHelp: t("new.message.linkHelp"),
95
+ submit: t("new.message.submit"),
96
+ }}
97
+ />
98
+ ),
99
+ } satisfies Record<AccountNewActionId, (ctx: AccountContext) => ReactNode>;
100
+
101
+ /** The New actions in menu order (D2), each with its popup — what the Inbox page offers. */
102
+ export const newActions: readonly AccountNewAction[] = accountNewActions.map(
103
+ (read) => ({
104
+ ...read,
105
+ renderPopup: popups[read.id],
106
+ }),
107
+ );