create-ailk 0.11.2 → 0.11.4
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/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +41 -28
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +29 -5
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +148 -21
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* account-data.ts — the base account area's server reads (#7430, spec 7423
|
|
3
|
+
* U4 · D6a, D8, D9, D12).
|
|
4
|
+
*
|
|
5
|
+
* One module the layout (the For-you aside), the Inbox and the item detail
|
|
6
|
+
* share, so every page reads the contributions in `lib/account-config.ts`
|
|
7
|
+
* the same way:
|
|
8
|
+
*
|
|
9
|
+
* - `accountSession(locale)` — the `AccountContext` every contribution
|
|
10
|
+
* receives (locale, the session's person, the `account` translator and
|
|
11
|
+
* the caller's forwarded cookie), or `null` with no session.
|
|
12
|
+
* - `loadInboxItems(ctx)` — every kind's items, newest first, with
|
|
13
|
+
* `failed` set when any kind's read rejected (D12: the Inbox then shows
|
|
14
|
+
* an error row with Retry, never a silently partial or empty list).
|
|
15
|
+
* - `loadForYou(locale)` — every For-you source's items plus the area's own
|
|
16
|
+
* "Add your name" item (an accepted applicant arrives with no name,
|
|
17
|
+
* #7418), with the same `failed` flag.
|
|
18
|
+
*
|
|
19
|
+
* `cache()` dedupes the reads within one request: the layout's aside and the
|
|
20
|
+
* Inbox's phone For you tab render the same items from one set of API calls.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
AccountContext,
|
|
25
|
+
AccountInboxItem,
|
|
26
|
+
AccountInboxKind,
|
|
27
|
+
AccountTranslate,
|
|
28
|
+
ForYouItem,
|
|
29
|
+
} from "@working-theory/templates/account";
|
|
30
|
+
import { localeHref } from "@working-theory/ui";
|
|
31
|
+
import { Icon } from "@working-theory/ui/primitives";
|
|
32
|
+
import { UserRound } from "lucide-react";
|
|
33
|
+
import { headers } from "next/headers";
|
|
34
|
+
import { getTranslations } from "next-intl/server";
|
|
35
|
+
import { cache, createElement } from "react";
|
|
36
|
+
|
|
37
|
+
import { auth } from "../../../../auth";
|
|
38
|
+
import { accountForYouSources, accountInboxKinds } from "../../../../lib/account-config";
|
|
39
|
+
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
40
|
+
|
|
41
|
+
/** The session's person, plus what the frame shows beyond the contract. */
|
|
42
|
+
export interface AccountSession {
|
|
43
|
+
ctx: AccountContext;
|
|
44
|
+
/** ISO-8601 time the person's account was created, when the session carries it. */
|
|
45
|
+
createdAt?: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The `AccountContext` for this request, or `null` when there is no session. */
|
|
49
|
+
export const accountSession = cache(
|
|
50
|
+
async (locale: string): Promise<AccountSession | null> => {
|
|
51
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
52
|
+
if (!session) return null;
|
|
53
|
+
// The contract's translator takes any key: a contribution's keys (a
|
|
54
|
+
// kind's `label`, a section's `title`) are data, which next-intl's
|
|
55
|
+
// literal-key typing cannot see. The runtime call is the same function.
|
|
56
|
+
const t = (await getTranslations({ locale, namespace: "account" })) as unknown as AccountTranslate;
|
|
57
|
+
const { name, email, emailVerified, createdAt } = session.user as {
|
|
58
|
+
name?: string | null;
|
|
59
|
+
email: string;
|
|
60
|
+
emailVerified: boolean;
|
|
61
|
+
createdAt?: Date | string | null;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
ctx: {
|
|
65
|
+
locale,
|
|
66
|
+
user: { name: name ? name : null, email, emailVerified },
|
|
67
|
+
t,
|
|
68
|
+
init: await forwardedCookieInit(),
|
|
69
|
+
},
|
|
70
|
+
...(createdAt ? { createdAt: new Date(createdAt).toISOString() } : {}),
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Logs a failed contribution read, PII-safe (`err.name` only — the
|
|
77
|
+
* `actions.ts` idiom): the person sees the error row; the operator sees why.
|
|
78
|
+
*/
|
|
79
|
+
export function logReadFailure(source: string, err: unknown): void {
|
|
80
|
+
console.error(
|
|
81
|
+
`[account] read failed source=${source} errName=${err instanceof Error ? err.name : "unknown"}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The detail route of one Inbox item. */
|
|
86
|
+
export function inboxItemHref(locale: string, kind: string, id: string): string {
|
|
87
|
+
return localeHref(
|
|
88
|
+
locale,
|
|
89
|
+
`/account/inbox/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Inbox rows ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/** One loaded item with the kind it came from — what a tab's row is built from. */
|
|
96
|
+
export interface LoadedInboxItem {
|
|
97
|
+
kind: AccountInboxKind;
|
|
98
|
+
item: AccountInboxItem;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface LoadedInbox {
|
|
102
|
+
items: LoadedInboxItem[];
|
|
103
|
+
/** True when any kind's read rejected (D12). */
|
|
104
|
+
failed: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const newestFirst = (a: LoadedInboxItem, b: LoadedInboxItem): number =>
|
|
108
|
+
(b.item.at ?? "").localeCompare(a.item.at ?? "");
|
|
109
|
+
|
|
110
|
+
/** Every kind's items, newest first. A rejected kind sets `failed`. */
|
|
111
|
+
export async function loadInboxItems(ctx: AccountContext): Promise<LoadedInbox> {
|
|
112
|
+
const settled = await Promise.allSettled(
|
|
113
|
+
accountInboxKinds.map(async (kind) =>
|
|
114
|
+
(await kind.load(ctx)).map((item) => ({ kind, item })),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
settled.forEach((r, i) => {
|
|
118
|
+
if (r.status === "rejected") logReadFailure(`kind:${accountInboxKinds[i]!.kind}`, r.reason);
|
|
119
|
+
});
|
|
120
|
+
const items = settled.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
|
|
121
|
+
return {
|
|
122
|
+
items: items.sort(newestFirst),
|
|
123
|
+
failed: settled.some((r) => r.status === "rejected"),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── For you ──────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export interface LoadedForYou {
|
|
130
|
+
items: ForYouItem[];
|
|
131
|
+
/** True when any source's read rejected (D12). */
|
|
132
|
+
failed: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The area's own For-you item: a member with no name is asked for one
|
|
137
|
+
* (operator fold-in 2026-10-01). It waits on them from the moment their
|
|
138
|
+
* account exists; with no creation time on the session, from now.
|
|
139
|
+
*/
|
|
140
|
+
function addNameItem(session: AccountSession, now: string): ForYouItem[] {
|
|
141
|
+
const { ctx } = session;
|
|
142
|
+
if (ctx.user.name) return [];
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
id: "add-name",
|
|
146
|
+
type: {
|
|
147
|
+
id: "profile",
|
|
148
|
+
label: ctx.t("forYou.types.profile"),
|
|
149
|
+
// The tag's glyph (DESIGN.md §5.4c: icon + label). An element, not a
|
|
150
|
+
// component, so it crosses the server → client boundary.
|
|
151
|
+
icon: createElement(Icon, { icon: UserRound, size: "xs", decorative: true }),
|
|
152
|
+
tone: "needs-you",
|
|
153
|
+
},
|
|
154
|
+
title: ctx.t("forYou.addName"),
|
|
155
|
+
time: { kind: "waiting", at: session.createdAt ?? now },
|
|
156
|
+
action: { kind: "open", href: localeHref(ctx.locale, "/account/settings#general") },
|
|
157
|
+
dismissible: true,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every For-you source's items, plus the area's own. Deduped per request. */
|
|
163
|
+
export const loadForYou = cache(
|
|
164
|
+
async (locale: string): Promise<LoadedForYou & { now: string }> => {
|
|
165
|
+
const now = new Date().toISOString();
|
|
166
|
+
const session = await accountSession(locale);
|
|
167
|
+
if (!session) return { items: [], failed: false, now };
|
|
168
|
+
const settled = await Promise.allSettled(
|
|
169
|
+
accountForYouSources.map((source) => source.load(session.ctx)),
|
|
170
|
+
);
|
|
171
|
+
settled.forEach((r, i) => {
|
|
172
|
+
if (r.status === "rejected") logReadFailure(`foryou:${accountForYouSources[i]!.id}`, r.reason);
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
items: [
|
|
176
|
+
...addNameItem(session, now),
|
|
177
|
+
...settled.flatMap((r) => (r.status === "fulfilled" ? r.value : [])),
|
|
178
|
+
],
|
|
179
|
+
failed: settled.some((r) => r.status === "rejected"),
|
|
180
|
+
now,
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client"; // the shell owns collapse state; sign-out is a client-side Better Auth call + router navigation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AccountFrame — the account area's SignedInShell composition (#7430, spec
|
|
5
|
+
* 7423 D13; DESIGN.md §2b). The layout (an RSC) resolves the strings, the
|
|
6
|
+
* logo and the For-you items; this component wires them to the shell with
|
|
7
|
+
* what only the client has: the current path and the sign-out call.
|
|
8
|
+
*
|
|
9
|
+
* `createAuthClient()` at module scope is the `sign-in-gate.tsx` idiom. Log
|
|
10
|
+
* out ends the session, then navigates home and refreshes so every RSC under
|
|
11
|
+
* the `(authed)` gate re-reads the (now absent) session.
|
|
12
|
+
*
|
|
13
|
+
* #7469 (DESIGN.md §2b as amended 2026-10-01, matching the Admin's §5.15) —
|
|
14
|
+
* below `md` the bar reads avatar · Inbox · For you · Help, with no count
|
|
15
|
+
* on any tab. For you is a page of the Inbox route (`?tab=for-you`): the
|
|
16
|
+
* frame reads the URL and hands the tab to the page (`PhoneTabProvider`),
|
|
17
|
+
* which shows one panel per tab. At `md`+ nothing changes — For you stays
|
|
18
|
+
* the aside.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
ForYouPanel,
|
|
23
|
+
PhoneTabProvider,
|
|
24
|
+
useForYouDismissed,
|
|
25
|
+
type ForYouItem,
|
|
26
|
+
type ForYouLabels,
|
|
27
|
+
} from "@working-theory/templates/account";
|
|
28
|
+
import {
|
|
29
|
+
SignedInShell,
|
|
30
|
+
type SignedInShellLabels,
|
|
31
|
+
type SignedInShellProps,
|
|
32
|
+
} from "@working-theory/templates/shells";
|
|
33
|
+
import { localeHref } from "@working-theory/ui";
|
|
34
|
+
import { Link } from "@working-theory/ui/primitives";
|
|
35
|
+
import { createAuthClient } from "better-auth/react";
|
|
36
|
+
import { Inbox, Settings, Sparkles } from "lucide-react";
|
|
37
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
38
|
+
import type { ReactElement, ReactNode } from "react";
|
|
39
|
+
|
|
40
|
+
import { ACCOUNT_PERSIST_KEY } from "./for-you-labels";
|
|
41
|
+
|
|
42
|
+
const authClient = createAuthClient();
|
|
43
|
+
|
|
44
|
+
/** The phone For you tab's `?tab=` value (#7469). */
|
|
45
|
+
const FOR_YOU_TAB = "for-you";
|
|
46
|
+
|
|
47
|
+
export interface AccountFrameLabels extends SignedInShellLabels {
|
|
48
|
+
/** The logo link's accessible name. */
|
|
49
|
+
home: string;
|
|
50
|
+
inbox: string;
|
|
51
|
+
/** The phone's For you tab label (#7469). */
|
|
52
|
+
forYou: string;
|
|
53
|
+
settings: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AccountFrameProps {
|
|
57
|
+
locale: string;
|
|
58
|
+
user: { name: string; email: string };
|
|
59
|
+
/** The site's logo (the layout's `siteLogo()`), at most 32px tall. */
|
|
60
|
+
logo: ReactNode;
|
|
61
|
+
helpItems: NonNullable<SignedInShellProps["helpItems"]>;
|
|
62
|
+
forYou: { items: readonly ForYouItem[]; now: string; labels: ForYouLabels };
|
|
63
|
+
labels: AccountFrameLabels;
|
|
64
|
+
children: ReactNode;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function AccountFrame({
|
|
68
|
+
locale,
|
|
69
|
+
user,
|
|
70
|
+
logo,
|
|
71
|
+
helpItems,
|
|
72
|
+
forYou,
|
|
73
|
+
labels,
|
|
74
|
+
children,
|
|
75
|
+
}: AccountFrameProps): ReactElement {
|
|
76
|
+
const router = useRouter();
|
|
77
|
+
const pathname = usePathname();
|
|
78
|
+
const searchParams = useSearchParams();
|
|
79
|
+
const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
|
|
80
|
+
|
|
81
|
+
const homeHref = localeHref(locale, "/");
|
|
82
|
+
const inboxHref = localeHref(locale, "/account");
|
|
83
|
+
// The Inbox row stays selected on an item's own page (/account/inbox/…).
|
|
84
|
+
const onInbox = pathname === inboxHref || pathname.startsWith(`${inboxHref}/inbox/`);
|
|
85
|
+
// #7469 — the phone tab: For you only on the Inbox route itself.
|
|
86
|
+
const phoneTab =
|
|
87
|
+
pathname === inboxHref && searchParams?.get("tab") === FOR_YOU_TAB ? FOR_YOU_TAB : "inbox";
|
|
88
|
+
|
|
89
|
+
const onSignOut = (): void => {
|
|
90
|
+
void (async () => {
|
|
91
|
+
try {
|
|
92
|
+
await authClient.signOut();
|
|
93
|
+
} catch {
|
|
94
|
+
// The refresh below re-reads the session either way: a sign-out that
|
|
95
|
+
// failed lands the person back on a page that still knows them.
|
|
96
|
+
}
|
|
97
|
+
router.push(homeHref);
|
|
98
|
+
router.refresh();
|
|
99
|
+
})();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const { home, inbox, forYou: forYouLabel, settings, ...shellLabels } = labels;
|
|
103
|
+
|
|
104
|
+
// D13 — the logo only: no box, no name, no popover; it links home. The
|
|
105
|
+
// same link is the phone top bar's leading cell.
|
|
106
|
+
const logoLink = (
|
|
107
|
+
<Link
|
|
108
|
+
href={homeHref}
|
|
109
|
+
variant="unstyled"
|
|
110
|
+
aria-label={home}
|
|
111
|
+
className="flex h-8 max-h-8 items-center rounded-control focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent)"
|
|
112
|
+
data-testid="account-site-logo"
|
|
113
|
+
>
|
|
114
|
+
{logo}
|
|
115
|
+
</Link>
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const forYouLeft = forYou.items.filter((item) => !dismissed.has(item.id));
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<SignedInShell
|
|
122
|
+
persistKey={ACCOUNT_PERSIST_KEY}
|
|
123
|
+
locale={locale}
|
|
124
|
+
navItems={[{ labelKey: "inbox", label: inbox, path: "/account", icon: Inbox }]}
|
|
125
|
+
tabItems={[
|
|
126
|
+
{ key: "inbox", label: inbox, href: inboxHref, icon: Inbox, isActive: onInbox && phoneTab === "inbox" },
|
|
127
|
+
{
|
|
128
|
+
key: FOR_YOU_TAB,
|
|
129
|
+
label: forYouLabel,
|
|
130
|
+
href: `${inboxHref}?tab=${FOR_YOU_TAB}`,
|
|
131
|
+
icon: Sparkles,
|
|
132
|
+
isActive: phoneTab === FOR_YOU_TAB,
|
|
133
|
+
},
|
|
134
|
+
]}
|
|
135
|
+
{...(onInbox ? { activePath: inboxHref } : {})}
|
|
136
|
+
user={user}
|
|
137
|
+
userMenuItems={[
|
|
138
|
+
{
|
|
139
|
+
id: "settings",
|
|
140
|
+
label: settings,
|
|
141
|
+
icon: Settings,
|
|
142
|
+
href: localeHref(locale, "/account/settings"),
|
|
143
|
+
},
|
|
144
|
+
]}
|
|
145
|
+
onSignOut={onSignOut}
|
|
146
|
+
userDisplay="avatar"
|
|
147
|
+
accountMenuLayout="card"
|
|
148
|
+
helpItems={helpItems}
|
|
149
|
+
railHeader={logoLink}
|
|
150
|
+
topBarStart={logoLink}
|
|
151
|
+
{...(forYouLeft.length > 0
|
|
152
|
+
? {
|
|
153
|
+
aside: (
|
|
154
|
+
<ForYouPanel
|
|
155
|
+
items={forYouLeft}
|
|
156
|
+
persistKey={ACCOUNT_PERSIST_KEY}
|
|
157
|
+
labels={forYou.labels}
|
|
158
|
+
locale={locale}
|
|
159
|
+
now={forYou.now}
|
|
160
|
+
/>
|
|
161
|
+
),
|
|
162
|
+
}
|
|
163
|
+
: {})}
|
|
164
|
+
labels={shellLabels}
|
|
165
|
+
>
|
|
166
|
+
<PhoneTabProvider tab={phoneTab}>{children}</PhoneTabProvider>
|
|
167
|
+
</SignedInShell>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* account/actions.ts —
|
|
4
|
+
* account/actions.ts — User settings › Billing's Server Action.
|
|
5
|
+
*
|
|
6
|
+
* `openBillingPortal` — User settings › Billing's Manage billing (#6038,
|
|
5
7
|
* D5): mints a Stripe billing-portal session on SUBMIT, never on render, and
|
|
6
8
|
* hands the browser straight to it via `redirect()`.
|
|
7
9
|
*
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ErrorRow — the account area's "a read failed" row (#7430, spec 7423 D12):
|
|
3
|
+
* the message and a Retry control, inside the same bordered row box the
|
|
4
|
+
* Inbox list uses. Never an empty list in its place.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { RowGroup } from "@working-theory/ui/blocks";
|
|
8
|
+
import { InlineMessage } from "@working-theory/ui/primitives";
|
|
9
|
+
import type { ReactElement } from "react";
|
|
10
|
+
|
|
11
|
+
import { RetryButton } from "./retry-button";
|
|
12
|
+
|
|
13
|
+
export function ErrorRow({
|
|
14
|
+
message,
|
|
15
|
+
retryLabel,
|
|
16
|
+
className,
|
|
17
|
+
}: {
|
|
18
|
+
message: string;
|
|
19
|
+
retryLabel: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}): ReactElement {
|
|
22
|
+
return (
|
|
23
|
+
<RowGroup aria-label={message} {...(className ? { className } : {})}>
|
|
24
|
+
<li
|
|
25
|
+
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"
|
|
26
|
+
data-testid="inbox-error"
|
|
27
|
+
>
|
|
28
|
+
<InlineMessage variant="error" className="text-body-sm">
|
|
29
|
+
{message}
|
|
30
|
+
</InlineMessage>
|
|
31
|
+
<RetryButton label={retryLabel} />
|
|
32
|
+
</li>
|
|
33
|
+
</RowGroup>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* for-you-labels.ts — the For-you panel's strings from the `account`
|
|
3
|
+
* namespace (#7430, spec §5.2 i18n), and the frame's persist key. Shared by
|
|
4
|
+
* the layout's aside and the Inbox's phone For you tab.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AccountTranslate, ForYouLabels } from "@working-theory/templates/account";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The account frame's `persistKey`: the shell's remembered collapse
|
|
11
|
+
* (`ailk:shell:account:*`) and the For-you dismissals
|
|
12
|
+
* (`ailk:account:foryou:dismissed`), shared by the aside and the Inbox's
|
|
13
|
+
* phone For you tab so a dismissal holds on both.
|
|
14
|
+
*/
|
|
15
|
+
export const ACCOUNT_PERSIST_KEY = "account";
|
|
16
|
+
|
|
17
|
+
export function forYouLabels(t: AccountTranslate): ForYouLabels {
|
|
18
|
+
return {
|
|
19
|
+
title: t("forYou.title"),
|
|
20
|
+
filter: t("forYou.filter"),
|
|
21
|
+
allTypes: t("forYou.allTypes"),
|
|
22
|
+
sort: t("forYou.sort"),
|
|
23
|
+
mostRelevant: t("forYou.mostRelevant"),
|
|
24
|
+
mostRecent: t("forYou.mostRecent"),
|
|
25
|
+
view: t("forYou.view"),
|
|
26
|
+
open: t("forYou.open"),
|
|
27
|
+
dismiss: t("forYou.dismiss"),
|
|
28
|
+
whyItMatters: t("forYou.whyItMatters"),
|
|
29
|
+
expectedOutcome: t("forYou.expectedOutcome"),
|
|
30
|
+
// The panel fills `{duration}` itself, so the placeholder passes through.
|
|
31
|
+
waiting: t("forYou.waiting", { duration: "{duration}" }),
|
|
32
|
+
empty: t("forYou.empty"),
|
|
33
|
+
showAll: t("forYou.showAll"),
|
|
34
|
+
close: t("forYou.close"),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/inbox/[kind]/[id] — one Inbox item (#7430, spec 7423 U4;
|
|
3
|
+
* the D8 detail contract).
|
|
4
|
+
*
|
|
5
|
+
* RSC, inside the account layout's SignedInShell (the rail keeps Inbox
|
|
6
|
+
* selected here). The item is found the way the contract says: its kind's
|
|
7
|
+
* `load` runs again and the `[id]` is matched — so the page shows only what
|
|
8
|
+
* the caller's own reads return. The kind's `renderDetail` fills the shared
|
|
9
|
+
* `InboxItemFrame`; the area supplies the back link, the caption (the kind's
|
|
10
|
+
* label) and the title, so every kind's detail reads as one frame.
|
|
11
|
+
*
|
|
12
|
+
* An unknown kind, or an id the kind did not load, is a not-found — never an
|
|
13
|
+
* empty frame. A failed read is not a not-found: it renders an error with
|
|
14
|
+
* Retry (D12's posture).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { InboxItemFrame } from "@working-theory/templates/account";
|
|
18
|
+
import { localeHref } from "@working-theory/ui";
|
|
19
|
+
import type { Metadata } from "next";
|
|
20
|
+
import { notFound } from "next/navigation";
|
|
21
|
+
import { getTranslations } from "next-intl/server";
|
|
22
|
+
import type { ReactElement } from "react";
|
|
23
|
+
|
|
24
|
+
import { accountInboxKinds } from "../../../../../../../lib/account-config";
|
|
25
|
+
import { accountSession, logReadFailure } from "../../../account-data";
|
|
26
|
+
import { ErrorRow } from "../../../error-row";
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
params: Promise<{ locale: string; kind: string; id: string }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A segment, percent-decoded when it decodes. The route param is matched in
|
|
34
|
+
* both forms (`matches` below), so an id is found whether or not the
|
|
35
|
+
* framework already decoded it — and an id that itself contains `%XX` still
|
|
36
|
+
* matches its raw form.
|
|
37
|
+
*/
|
|
38
|
+
function decodeSegment(segment: string): string {
|
|
39
|
+
try {
|
|
40
|
+
return decodeURIComponent(segment);
|
|
41
|
+
} catch {
|
|
42
|
+
return segment;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
47
|
+
const { locale } = await params;
|
|
48
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
49
|
+
return { title: t("inbox.title") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default async function AccountInboxItemPage({ params }: Props): Promise<ReactElement> {
|
|
53
|
+
const { locale, kind: kindSegment, id: idSegment } = await params;
|
|
54
|
+
const matches = (value: string, segment: string): boolean =>
|
|
55
|
+
value === segment || value === decodeSegment(segment);
|
|
56
|
+
const kind = accountInboxKinds.find((k) => matches(k.kind, kindSegment));
|
|
57
|
+
if (!kind) notFound();
|
|
58
|
+
|
|
59
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
60
|
+
const session = await accountSession(locale);
|
|
61
|
+
if (!session) notFound();
|
|
62
|
+
|
|
63
|
+
const back = { href: localeHref(locale, "/account"), label: t("inbox.back") };
|
|
64
|
+
|
|
65
|
+
let items;
|
|
66
|
+
try {
|
|
67
|
+
items = await kind.load(session.ctx);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logReadFailure(`kind:${kind.kind}`, err);
|
|
70
|
+
return (
|
|
71
|
+
<div className="max-w-3xl py-8" data-testid="account-item">
|
|
72
|
+
<ErrorRow message={t("inbox.itemError")} retryLabel={t("inbox.retry")} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const item = items.find((candidate) => matches(candidate.id, idSegment));
|
|
78
|
+
if (!item) notFound();
|
|
79
|
+
|
|
80
|
+
const detail = kind.renderDetail(item, session.ctx);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<InboxItemFrame
|
|
84
|
+
back={back}
|
|
85
|
+
caption={session.ctx.t(kind.label)}
|
|
86
|
+
title={item.title}
|
|
87
|
+
lead={detail.lead}
|
|
88
|
+
status={detail.status}
|
|
89
|
+
{...(detail.actions ? { actions: detail.actions } : {})}
|
|
90
|
+
fields={detail.fields}
|
|
91
|
+
testIdPrefix="account-item"
|
|
92
|
+
>
|
|
93
|
+
{detail.body}
|
|
94
|
+
</InboxItemFrame>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account layout — the base account area's frame (#7430, spec 7423 U4 ·
|
|
3
|
+
* D4, D13; DESIGN.md §2b).
|
|
4
|
+
*
|
|
5
|
+
* RSC. Every `/account` route renders inside `SignedInShell` (through the
|
|
6
|
+
* client `AccountFrame`): the site's logo as the rail's top tile, linking
|
|
7
|
+
* home; Inbox as the rail's only row; a footer of the avatar and ? alone;
|
|
8
|
+
* the card avatar menu (name, then Settings and Log out); the ? help menu
|
|
9
|
+
* (Contact support, and Help center when `AILK_HELP_CENTER_URL` is set);
|
|
10
|
+
* and below `md` a tab bar reading avatar · Inbox · For you · Help (#7469).
|
|
11
|
+
* The locale layout renders no marketing frame and no consent Banner on
|
|
12
|
+
* these paths (D4, `isAccountPath`).
|
|
13
|
+
*
|
|
14
|
+
* The aside is the For-you panel, fed by `accountForYouSources` plus the
|
|
15
|
+
* area's own "Add your name" item (`loadForYou`, deduped per request with
|
|
16
|
+
* the Inbox's phone For you tab); with no items there is no aside.
|
|
17
|
+
*
|
|
18
|
+
* The `(authed)` layout above owns the signed-out gate, so a missing session
|
|
19
|
+
* here is a race (it expired between the two layouts): the page renders
|
|
20
|
+
* unframed and degrades on its own.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { localeHref } from "@working-theory/ui";
|
|
24
|
+
import { getTranslations } from "next-intl/server";
|
|
25
|
+
import type { ReactElement, ReactNode } from "react";
|
|
26
|
+
|
|
27
|
+
import { siteLogo } from "../../../../lib/site-logo";
|
|
28
|
+
|
|
29
|
+
import { accountSession, loadForYou } from "./account-data";
|
|
30
|
+
import { AccountFrame } from "./account-frame";
|
|
31
|
+
import { forYouLabels } from "./for-you-labels";
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
params: Promise<{ locale: string }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default async function AccountLayout({
|
|
39
|
+
children,
|
|
40
|
+
params,
|
|
41
|
+
}: Props): Promise<ReactElement> {
|
|
42
|
+
const { locale } = await params;
|
|
43
|
+
const session = await accountSession(locale);
|
|
44
|
+
if (!session) return <>{children}</>;
|
|
45
|
+
|
|
46
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
47
|
+
const forYou = await loadForYou(locale);
|
|
48
|
+
const { name, email } = session.ctx.user;
|
|
49
|
+
const helpCenterUrl = process.env.AILK_HELP_CENTER_URL;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<AccountFrame
|
|
53
|
+
locale={locale}
|
|
54
|
+
user={{ name: name ?? email, email }}
|
|
55
|
+
logo={siteLogo()}
|
|
56
|
+
helpItems={[
|
|
57
|
+
{
|
|
58
|
+
id: "support",
|
|
59
|
+
label: t("frame.contactSupport"),
|
|
60
|
+
href: localeHref(locale, "/account/support"),
|
|
61
|
+
},
|
|
62
|
+
...(helpCenterUrl
|
|
63
|
+
? [{ id: "help-center", label: t("frame.helpCenter"), href: helpCenterUrl }]
|
|
64
|
+
: []),
|
|
65
|
+
]}
|
|
66
|
+
forYou={{ items: forYou.items, now: forYou.now, labels: forYouLabels(session.ctx.t) }}
|
|
67
|
+
labels={{
|
|
68
|
+
home: t("frame.home"),
|
|
69
|
+
inbox: t("frame.inbox"),
|
|
70
|
+
forYou: t("frame.forYou"),
|
|
71
|
+
settings: t("frame.settings"),
|
|
72
|
+
signOut: t("frame.signOut"),
|
|
73
|
+
help: t("frame.help"),
|
|
74
|
+
accountMenu: t("frame.accountMenu"),
|
|
75
|
+
tabBar: t("frame.tabBar"),
|
|
76
|
+
sidebar: t("frame.sidebar"),
|
|
77
|
+
collapseSidebar: t("frame.collapseSidebar"),
|
|
78
|
+
expandSidebar: t("frame.expandSidebar"),
|
|
79
|
+
collapseAside: t("frame.collapseAside"),
|
|
80
|
+
expandAside: t("frame.expandAside"),
|
|
81
|
+
aside: t("forYou.title"),
|
|
82
|
+
openAside: t("frame.openAside"),
|
|
83
|
+
closeAside: t("frame.closeAside"),
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</AccountFrame>
|
|
88
|
+
);
|
|
89
|
+
}
|