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,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
+ );
@@ -0,0 +1,57 @@
1
+ /**
2
+ * /[locale]/account/notifications — User settings › Notifications (#8339
3
+ * D8, NEW).
4
+ *
5
+ * RSC, inside the account layout's SignedInShell with the rail visible, in
6
+ * the member's four-tab settings frame (D7). Holds the former Privacy
7
+ * section's cookie-settings link, moved here verbatim from General (#8339
8
+ * C6) — for now; notification preferences are a later unit's scope.
9
+ */
10
+
11
+ import { AccountSettingsFrame, AccountSettingsPane } from "@working-theory/templates/account";
12
+ import { localeHref } from "@working-theory/ui";
13
+ import { Link } from "@working-theory/ui/primitives";
14
+ import type { Metadata } from "next";
15
+ import { getTranslations } from "next-intl/server";
16
+ import type { ReactElement } from "react";
17
+
18
+ import { accountSession } from "../account-data";
19
+ import { memberSettingsFrame, type Translate } from "../settings-frame";
20
+
21
+ interface Props {
22
+ params: Promise<{ locale: string }>;
23
+ }
24
+
25
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
26
+ const { locale } = await params;
27
+ const t = await getTranslations({ locale, namespace: "account" });
28
+ return { title: t("settings.notifications.title"), description: t("settings.privacy.lead") };
29
+ }
30
+
31
+ export default async function AccountNotificationsPage({ params }: Props): Promise<ReactElement> {
32
+ const { locale } = await params;
33
+ const t = await getTranslations({ locale, namespace: "account" });
34
+ const session = await accountSession(locale);
35
+ const frame = memberSettingsFrame(locale, t as unknown as Translate, "notifications");
36
+ const name = session?.ctx.user.name || session?.ctx.user.email || "";
37
+
38
+ return (
39
+ <AccountSettingsFrame {...frame} name={name}>
40
+ <AccountSettingsPane
41
+ id="account-settings-notifications"
42
+ title={t("settings.notifications.title")}
43
+ lead={t("settings.privacy.lead")}
44
+ >
45
+ <div>
46
+ <Link
47
+ href={localeHref(locale, "/privacy#cookie-settings")}
48
+ className="inline-flex min-h-6 items-center"
49
+ data-testid="account-cookie-settings"
50
+ >
51
+ {t("settings.privacy.cookies")}
52
+ </Link>
53
+ </div>
54
+ </AccountSettingsPane>
55
+ </AccountSettingsFrame>
56
+ );
57
+ }
@@ -1,19 +1,18 @@
1
1
  /**
2
2
  * /[locale]/account — the base account area's Inbox (#7430, spec 7423 U4 ·
3
- * D6a, D9, D12; DESIGN.md §5.4).
3
+ * D6a, D9, D12; #8231 D3; DESIGN.md §5.4).
4
4
  *
5
5
  * RSC, rendered inside the account layout's SignedInShell. One filtered
6
- * list: the D6a tabs Recents · Suggested · Scheduled · All, each with a
7
- * count, Recents selected, through the shared `InboxList`
8
- * (`@working-theory/templates/account`):
6
+ * list: the tabs All · Purchases · Scheduled, each with a count, All
7
+ * selected, through the shared `InboxList` (`@working-theory/templates/account`):
9
8
  *
10
- * - Recents — the kinds whose section is `updates` (the base `result` and
11
- * `purchase`), newest first.
12
- * - Suggested — nothing in the base area; no contribution section maps
13
- * here yet (a module that proposes things fills it).
14
- * - Scheduled — the kinds whose section is `upcoming` (the old Upcoming);
15
- * none in the base area.
16
- * - All — every row above, newest first.
9
+ * - All — every loaded row, newest first. The kinds in `updates` or
10
+ * `needs-you` (`result`, `piece`, `message`) show here only.
11
+ * - Purchases — the kinds whose section is `purchases` (the `purchase`
12
+ * kind, and any a module adds), newest first.
13
+ * - Scheduled — the kinds whose section is `upcoming` (`appointment`):
14
+ * first the items still upcoming, soonest first (no time last), then the
15
+ * rest, newest first. Each kind's own read decides which is upcoming.
17
16
  *
18
17
  * What needs the person is never an Inbox row: `email-unverified`,
19
18
  * `payment-due` and "Add your name" are For-you items (D9 as amended). From
@@ -33,16 +32,24 @@
33
32
  * D12 — any read that fails (a kind or a For-you source) renders an error
34
33
  * row with Retry above the list; the list then holds what DID load, never a
35
34
  * silently empty Inbox.
35
+ *
36
+ * #8233 D4 — New sits in the h1's header row (and, below `md`, in the
37
+ * frame's phone top bar): its rows are `new-popups.tsx`'s `newActions` — the
38
+ * `accountNewActions` of `lib/account-config.ts` with their popups bound —
39
+ * whose `available` read resolved `true`, each opening its popup over the
40
+ * Inbox. None offered (a Free build) → no New at all. A read that rejected
41
+ * drops its row and shows the error row too — a failed read is never silent.
36
42
  */
37
43
 
38
44
  import {
39
45
  ForYouPanel,
40
46
  InboxList,
41
47
  PhoneTabPanel,
48
+ type AccountContext,
42
49
  type InboxListRow,
43
50
  } from "@working-theory/templates/account";
44
51
  import { Heading, Text } from "@working-theory/ui/primitives";
45
- import { ClipboardList, Receipt, type LucideProps } from "lucide-react";
52
+ import { CalendarClock, ClipboardList, Receipt, type LucideProps } from "lucide-react";
46
53
  import type { Metadata } from "next";
47
54
  import { getTranslations } from "next-intl/server";
48
55
  import type { ComponentType, ReactElement } from "react";
@@ -52,10 +59,13 @@ import {
52
59
  inboxItemHref,
53
60
  loadForYou,
54
61
  loadInboxItems,
62
+ logReadFailure,
55
63
  type LoadedInboxItem,
56
64
  } from "./account-data";
57
65
  import { ErrorRow } from "./error-row";
58
66
  import { ACCOUNT_PERSIST_KEY, forYouLabels } from "./for-you-labels";
67
+ import { NewMenu, type NewMenuAction } from "./new-menu";
68
+ import { newActions } from "./new-popups";
59
69
 
60
70
  interface Props {
61
71
  params: Promise<{ locale: string }>;
@@ -71,8 +81,37 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
71
81
  const KIND_ICON: Record<string, ComponentType<LucideProps>> = {
72
82
  result: ClipboardList,
73
83
  purchase: Receipt,
84
+ appointment: CalendarClock,
74
85
  };
75
86
 
87
+ /** Soonest first; an item with no time sorts last. */
88
+ function soonestFirst(a: LoadedInboxItem, b: LoadedInboxItem): number {
89
+ if (a.item.at === undefined) return b.item.at === undefined ? 0 : 1;
90
+ if (b.item.at === undefined) return -1;
91
+ return Date.parse(a.item.at) - Date.parse(b.item.at);
92
+ }
93
+
94
+ /** The New actions this caller is offered; a rejected read drops its row and sets `failed`. */
95
+ async function loadNewActions(
96
+ ctx: AccountContext,
97
+ ): Promise<{ actions: NewMenuAction[]; failed: boolean }> {
98
+ const settled = await Promise.allSettled(newActions.map((action) => action.available(ctx)));
99
+ const actions: NewMenuAction[] = [];
100
+ settled.forEach((result, i) => {
101
+ const action = newActions[i]!;
102
+ if (result.status === "rejected") logReadFailure(`new:${action.id}`, result.reason);
103
+ else if (result.value)
104
+ actions.push({ id: action.id, label: ctx.t(action.label), popup: action.renderPopup(ctx) });
105
+ });
106
+ return { actions, failed: settled.some((result) => result.status === "rejected") };
107
+ }
108
+
109
+ /** Upcoming items first, soonest first; then the rest, as loaded (newest first). */
110
+ function scheduledOrder(rows: readonly LoadedInboxItem[]): LoadedInboxItem[] {
111
+ const upcoming = rows.filter(({ item }) => item.upcoming === true).sort(soonestFirst);
112
+ return [...upcoming, ...rows.filter(({ item }) => item.upcoming !== true)];
113
+ }
114
+
76
115
  export default async function AccountInboxPage({ params }: Props): Promise<ReactElement> {
77
116
  const { locale } = await params;
78
117
  const t = await getTranslations({ locale, namespace: "account" });
@@ -80,9 +119,17 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
80
119
 
81
120
  // The contract's translator, for keys that are data (a kind's `label`).
82
121
  const translate = session?.ctx.t ?? ((key: string): string => key);
83
- const [inbox, forYou] = session
84
- ? await Promise.all([loadInboxItems(session.ctx), loadForYou(locale)])
85
- : [{ items: [], failed: false }, { items: [], failed: false, now: "" }];
122
+ const [inbox, forYou, newMenu] = session
123
+ ? await Promise.all([
124
+ loadInboxItems(session.ctx),
125
+ loadForYou(locale),
126
+ loadNewActions(session.ctx),
127
+ ])
128
+ : [
129
+ { items: [], failed: false },
130
+ { items: [], failed: false, now: "" },
131
+ { actions: [], failed: false },
132
+ ];
86
133
 
87
134
  const dateFormat = new Intl.DateTimeFormat(locale, {
88
135
  month: "short",
@@ -101,19 +148,27 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
101
148
  };
102
149
  };
103
150
 
104
- const recents = inbox.items.filter(({ kind }) => kind.section === "updates").map(toRow);
105
- const scheduled = inbox.items.filter(({ kind }) => kind.section === "upcoming").map(toRow);
106
151
  const all = inbox.items.map(toRow);
107
- const failed = inbox.failed || forYou.failed;
152
+ const purchases = inbox.items.filter(({ kind }) => kind.section === "purchases").map(toRow);
153
+ const scheduled = scheduledOrder(
154
+ inbox.items.filter(({ kind }) => kind.section === "upcoming"),
155
+ ).map(toRow);
156
+ const failed = inbox.failed || forYou.failed || newMenu.failed;
108
157
 
109
158
  return (
110
159
  // The phone's top bar already clears the canvas — no top inset there.
111
160
  <div className="max-w-3xl py-8 max-md:pt-0" data-testid="account-inbox">
112
161
  {/* The page's one h1, on both phone tabs; visually hidden on the phone,
113
- which has no large title (§5.15). */}
114
- <Heading level={1} visual={2} className="max-md:sr-only">
115
- {t("inbox.title")}
116
- </Heading>
162
+ which has no large title (§5.15). New sits beside it (#8233 D4). */}
163
+ <div className="flex items-center justify-between gap-2">
164
+ <Heading level={1} visual={2} className="max-md:sr-only">
165
+ {t("inbox.title")}
166
+ </Heading>
167
+ <NewMenu
168
+ actions={newMenu.actions}
169
+ labels={{ trigger: t("new.trigger"), close: t("new.close") }}
170
+ />
171
+ </div>
117
172
  <PhoneTabPanel tabs={["inbox"]} desktop data-testid="inbox-panel-inbox">
118
173
  <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight hidden md:block">
119
174
  {t("inbox.lead")}
@@ -129,17 +184,21 @@ export default async function AccountInboxPage({ params }: Props): Promise<React
129
184
 
130
185
  <InboxList
131
186
  tabs={[
132
- { id: "recents", label: t("inbox.tabs.recents"), count: recents.length, rows: recents },
133
- { id: "suggested", label: t("inbox.tabs.suggested"), count: 0, rows: [] },
187
+ { id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
188
+ {
189
+ id: "purchases",
190
+ label: t("inbox.tabs.purchases"),
191
+ count: purchases.length,
192
+ rows: purchases,
193
+ },
134
194
  {
135
195
  id: "scheduled",
136
196
  label: t("inbox.tabs.scheduled"),
137
197
  count: scheduled.length,
138
198
  rows: scheduled,
139
199
  },
140
- { id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
141
200
  ]}
142
- defaultTab="recents"
201
+ defaultTab="all"
143
202
  {...(failed ? {} : { emptyLine: t("inbox.empty") })}
144
203
  />
145
204
  </PhoneTabPanel>