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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,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
|
|
7
|
-
*
|
|
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
|
-
* -
|
|
11
|
-
* `
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
* - Scheduled — the kinds whose section is `upcoming` (
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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([
|
|
85
|
-
|
|
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
|
|
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
|
-
<
|
|
115
|
-
{
|
|
116
|
-
|
|
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: "
|
|
133
|
-
{
|
|
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="
|
|
201
|
+
defaultTab="all"
|
|
143
202
|
{...(failed ? {} : { emptyLine: t("inbox.empty") })}
|
|
144
203
|
/>
|
|
145
204
|
</PhoneTabPanel>
|