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.
Files changed (76) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/surfaces.d.ts +4 -5
  4. package/dist/surfaces.js +50 -8
  5. package/package.json +3 -3
  6. package/templates/.env.example +29 -0
  7. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  8. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  9. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  10. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  11. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  12. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  13. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  14. package/templates/apps/api/src/server.ts +28 -0
  15. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  16. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  17. package/templates/apps/api/src/vercel-handler.ts +58 -12
  18. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  19. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  20. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  21. package/templates/apps/web/.env.example +4 -0
  22. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  23. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  24. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  25. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  26. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  27. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  28. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  29. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  30. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  31. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  32. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
  35. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  36. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  37. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  38. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
  39. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  40. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  43. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
  45. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  46. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  47. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  49. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  50. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  51. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  52. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  53. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  54. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  55. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  56. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  57. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  58. package/templates/apps/web/app/[locale]/layout.tsx +41 -28
  59. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  60. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  61. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  62. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  63. package/templates/apps/web/auth.ts +29 -5
  64. package/templates/apps/web/jest.config.cjs +5 -0
  65. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  66. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  67. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  68. package/templates/apps/web/lib/account-config.ts +219 -11
  69. package/templates/apps/web/lib/auth-email.ts +104 -2
  70. package/templates/apps/web/lib/auth-env.ts +37 -5
  71. package/templates/apps/web/lib/auth-routes.ts +33 -4
  72. package/templates/apps/web/messages/en.json +148 -21
  73. package/templates/database/CHANGELOG.md +14 -0
  74. package/templates/database/package.json +1 -1
  75. package/versions.json +33 -33
  76. 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 — the update-your-card banner's Server Action (#6038,
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
+ }