create-ailk 0.11.2 → 0.11.3

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 +297 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  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 +143 -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 +88 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  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 +147 -21
  73. package/templates/database/CHANGELOG.md +7 -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
@@ -1,65 +1,50 @@
1
1
  /**
2
- * /[locale]/account — the signed-in respondent's own profile, results,
3
- * purchases, and subscription status (#4984, #6038).
2
+ * /[locale]/account — the base account area's Inbox (#7430, spec 7423 U4 ·
3
+ * D6a, D9, D12; DESIGN.md §5.4).
4
4
  *
5
- * RSC. The `(authed)` layout is the gate — it already resolved a session or
6
- * rendered `SignInGate`; this page reads the session again ONLY for the
7
- * profile fields (name / email / verified). Every data read goes through
8
- * `@working-theory/api-client` with the caller's cookie forwarded
9
- * (`../../../../lib/server-api`, D9): `waitlistSignups.me(key)` once per key
10
- * in `lib/account-config.ts` (Results), `flowCheckouts.me()` (Purchases),
11
- * and `subscription.me()` (the update-your-card banner, #6038). A 404 (no
12
- * signup / no user row for that key / commerce module off) or a 401/403
13
- * (session missing, email unverified — the profile row already says so) is
14
- * skipped, never thrown; every other error rethrows to the route error
15
- * boundary.
5
+ * RSC, rendered inside the account layout's SignedInShell. One filtered
6
+ * list: the D6a tabs Recents · Suggested · Scheduled · All, each with a
7
+ * count, Recents selected, through the shared `InboxList`
8
+ * (`@working-theory/templates/account`):
16
9
  *
17
- * Chrome: this page composes `AppShell` + `SidebarNav` directly, with this
18
- * page's own three in-page anchors. The page renders inside the locale
19
- * layout (which owns the marketing Nav/Footer) — the operator's decision;
20
- * there is no second MarketingShell here.
10
+ * - Recents — the kinds whose section is `updates` (the base `result` and
11
+ * `purchase`), newest first.
12
+ * - Suggested — nothing in the base area; no contribution section maps
13
+ * here yet (a module that proposes things fills it).
14
+ * - Scheduled — the kinds whose section is `upcoming` (the old Upcoming);
15
+ * none in the base area.
16
+ * - All — every row above, newest first.
17
+ *
18
+ * What needs the person is never an Inbox row: `email-unverified`,
19
+ * `payment-due` and "Add your name" are For-you items (D9 as amended). From
20
+ * `md` up they are the layout's aside; below `md` this page shows them inline
21
+ * as the For you stack, between the header and the tabs — absent when empty.
22
+ *
23
+ * D12 — any read that fails (a kind or a For-you source) renders an error
24
+ * row with Retry above the list; the list then holds what DID load, never a
25
+ * silently empty Inbox.
21
26
  */
22
27
 
23
28
  import {
24
- flowCheckouts,
25
- subscription,
26
- waitlistSignups,
27
- type FlowCheckoutMeItem,
28
- type SubscriptionMeResponse,
29
- type WaitlistSignupMeResponse,
30
- } from "@working-theory/api-client";
31
- import { AppShell } from "@working-theory/templates/shells";
32
- import { localeHref } from "@working-theory/ui";
33
- import { DescriptionList, SubscriptionStatusBanner } from "@working-theory/ui/blocks";
34
- import { SidebarNav, type SidebarNavItem } from "@working-theory/ui/chrome";
35
- import {
36
- Badge,
37
- Button,
38
- EmptyState,
39
- Heading,
40
- MoneyAmount,
41
- Table,
42
- TableBody,
43
- TableCell,
44
- TableHead,
45
- TableHeader,
46
- TableRow,
47
- Text,
48
- } from "@working-theory/ui/primitives";
49
- import { ClipboardList, Receipt, UserRound } from "lucide-react";
29
+ ForYouStack,
30
+ InboxList,
31
+ type InboxListRow,
32
+ } from "@working-theory/templates/account";
33
+ import { Heading, Text } from "@working-theory/ui/primitives";
34
+ import { ClipboardList, Receipt, type LucideProps } from "lucide-react";
50
35
  import type { Metadata } from "next";
51
- import { headers } from "next/headers";
52
36
  import { getTranslations } from "next-intl/server";
53
- import type { ReactElement } from "react";
54
-
55
- import { auth } from "../../../../auth";
56
- import { accountExperimentKeys } from "../../../../lib/account-config";
57
- import { forwardedCookieInit, unwrapApiResult } from "../../../../lib/server-api";
58
- import { ForbiddenState } from "../waitlist/components/ForbiddenState";
59
- import { formatCount, formatDateTime } from "../waitlist/format";
37
+ import type { ComponentType, ReactElement } from "react";
60
38
 
61
- import { openBillingPortal } from "./actions";
62
- import { SignOutButton } from "./sign-out-button";
39
+ import {
40
+ accountSession,
41
+ inboxItemHref,
42
+ loadForYou,
43
+ loadInboxItems,
44
+ type LoadedInboxItem,
45
+ } from "./account-data";
46
+ import { ErrorRow } from "./error-row";
47
+ import { ACCOUNT_PERSIST_KEY, forYouLabels } from "./for-you-labels";
63
48
 
64
49
  interface Props {
65
50
  params: Promise<{ locale: string }>;
@@ -68,257 +53,95 @@ interface Props {
68
53
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
69
54
  const { locale } = await params;
70
55
  const t = await getTranslations({ locale, namespace: "account" });
71
- return { title: t("title"), description: t("description") };
72
- }
73
-
74
- /**
75
- * Statuses that mean "nothing to show for this caller" rather than "the read
76
- * failed": 404 (no signup for that experiment / no user row), 401/403 (no
77
- * session or email unverified — the profile section already renders the
78
- * unverified badge, so no second error surface is needed).
79
- */
80
- const SKIPPED_STATUSES = new Set([401, 403, 404]);
81
-
82
- type CookieInit = Awaited<ReturnType<typeof forwardedCookieInit>>;
83
-
84
- /** One `waitlistSignups.me` per configured key; skipped keys drop out. */
85
- async function readSignups(init: CookieInit): Promise<WaitlistSignupMeResponse[]> {
86
- const results = await Promise.all(
87
- accountExperimentKeys.map(async (key) => {
88
- const res = await waitlistSignups.me(key, init);
89
- if (!res.ok && SKIPPED_STATUSES.has(res.status)) return null;
90
- return unwrapApiResult(Promise.resolve(res));
91
- }),
92
- );
93
- return results.filter((r): r is WaitlistSignupMeResponse => r !== null);
56
+ return { title: t("inbox.title"), description: t("description") };
94
57
  }
95
58
 
96
- async function readCheckouts(init: CookieInit): Promise<FlowCheckoutMeItem[]> {
97
- const res = await flowCheckouts.me(init);
98
- if (!res.ok && SKIPPED_STATUSES.has(res.status)) return [];
99
- return unwrapApiResult(Promise.resolve(res));
100
- }
101
-
102
- /**
103
- * The update-your-card banner's read (#6038). `null` covers both "nothing
104
- * to show" (401/403/404 — the commerce-disabled-build 404 included, D8) and
105
- * "no row" (a healthy subscriber, `actionRequired: false`) — the page's
106
- * render condition below is `sub?.actionRequired`, so both collapse to no
107
- * banner without a separate branch.
108
- */
109
- async function readSubscription(init: CookieInit): Promise<SubscriptionMeResponse | null> {
110
- const res = await subscription.me(init);
111
- if (!res.ok && SKIPPED_STATUSES.has(res.status)) return null; // AC-4, the existing constant
112
- return unwrapApiResult(Promise.resolve(res));
113
- }
59
+ /** The base kinds' row glyphs — a bare icon, never an icon square (§5.4). */
60
+ const KIND_ICON: Record<string, ComponentType<LucideProps>> = {
61
+ result: ClipboardList,
62
+ purchase: Receipt,
63
+ };
114
64
 
115
- export default async function AccountPage({ params }: Props): Promise<ReactElement> {
65
+ export default async function AccountInboxPage({ params }: Props): Promise<ReactElement> {
116
66
  const { locale } = await params;
117
67
  const t = await getTranslations({ locale, namespace: "account" });
118
- const session = await auth.api.getSession({ headers: await headers() });
119
-
120
- if (!session) {
121
- // Unreachable behind the (authed) layout's gate; rendered rather than
122
- // thrown so a race (session expired between layout and page) degrades
123
- // to the dashboard's shared 401/403 shape instead of an error boundary.
124
- const tCommon = await getTranslations({ locale, namespace: "waitlistDashboard.common" });
125
- return (
126
- <ForbiddenState
127
- headline={tCommon("forbiddenHeadline")}
128
- description={tCommon("forbiddenDescription")}
129
- />
130
- );
131
- }
132
-
133
- const init = await forwardedCookieInit();
134
- const [signups, checkouts, sub] = await Promise.all([
135
- readSignups(init),
136
- readCheckouts(init),
137
- readSubscription(init),
138
- ]);
139
-
140
- const { name, email, emailVerified } = session.user;
141
- const displayName = name || email;
142
- const siteName = process.env.NEXT_PUBLIC_SITE_NAME ?? "AI Launch Kit";
143
- const unavailable = t("results.unavailable");
144
-
145
- const navItems: SidebarNavItem[] = [
146
- { labelKey: "profile", label: t("nav.profile"), path: "/account#profile", icon: UserRound },
147
- { labelKey: "results", label: t("nav.results"), path: "/account#results", icon: ClipboardList },
148
- { labelKey: "purchases", label: t("nav.purchases"), path: "/account#purchases", icon: Receipt },
149
- ];
150
-
151
- const sidebar = (
152
- <SidebarNav
153
- locale={locale}
154
- navItems={navItems}
155
- activePath={localeHref(locale, "/account")}
156
- logo={
157
- <Text as="span" variant="body-sm" className="font-semibold">
158
- {siteName}
159
- </Text>
160
- }
161
- user={{ name: displayName }}
162
- />
163
- );
68
+ const session = await accountSession(locale);
69
+
70
+ // The contract's translator, for keys that are data (a kind's `label`).
71
+ const translate = session?.ctx.t ?? ((key: string): string => key);
72
+ const [inbox, forYou] = session
73
+ ? await Promise.all([loadInboxItems(session.ctx), loadForYou(locale)])
74
+ : [{ items: [], failed: false }, { items: [], failed: false, now: "" }];
75
+
76
+ const dateFormat = new Intl.DateTimeFormat(locale, {
77
+ month: "short",
78
+ day: "numeric",
79
+ timeZone: "UTC",
80
+ });
81
+ const toRow = ({ kind, item }: LoadedInboxItem): InboxListRow => {
82
+ const label = translate(kind.label);
83
+ const icon = KIND_ICON[kind.kind];
84
+ return {
85
+ id: `${kind.kind}:${item.id}`,
86
+ ...(icon ? { icon } : {}),
87
+ caption: item.at ? `${label} · ${dateFormat.format(new Date(item.at))}` : label,
88
+ title: item.title,
89
+ href: inboxItemHref(locale, kind.kind, item.id),
90
+ };
91
+ };
92
+
93
+ const recents = inbox.items.filter(({ kind }) => kind.section === "updates").map(toRow);
94
+ const scheduled = inbox.items.filter(({ kind }) => kind.section === "upcoming").map(toRow);
95
+ const all = inbox.items.map(toRow);
96
+ const failed = inbox.failed || forYou.failed;
164
97
 
165
98
  return (
166
- <AppShell sidebar={sidebar} title={siteName}>
167
- <div className="flex flex-col gap-10" data-testid="account-page">
168
- <div>
169
- <Heading level={1}>{t("title")}</Heading>
170
- <Text as="p" variant="body-sm" tone="muted" className="mt-1">
171
- {t("description")}
172
- </Text>
173
- </div>
174
-
175
- {sub?.actionRequired ? (
176
- <SubscriptionStatusBanner
177
- headline={t("subscription.headline")}
178
- description={
179
- sub.status === "canceled" && sub.accessRevokesAt
180
- ? t("subscription.descriptionCanceled", {
181
- date: formatDateTime(sub.accessRevokesAt, locale),
182
- })
183
- : t("subscription.descriptionPastDue")
184
- }
185
- tone={sub.status === "canceled" ? "error" : "warning"}
186
- action={
187
- sub.portalAvailable ? (
188
- <form action={openBillingPortal}>
189
- {/* variant="unstyled" + text-current/border-current, not variant="secondary" —
190
- a per-mode-fixed color fails contrast against the banner's warning/error
191
- fill in dark mode (design-review F4); size="big" for the 44px touch-target
192
- floor this is a mobile CTA under (design-review F5, Rule 14). */}
193
- <Button
194
- type="submit"
195
- variant="unstyled"
196
- size="big"
197
- className="rounded-control border-thin border-current bg-transparent px-control-x text-current hover:opacity-90 active:opacity-80"
198
- >
199
- {t("subscription.action")}
200
- </Button>
201
- </form>
202
- ) : undefined
203
- }
204
- />
205
- ) : null}
206
-
207
- <section id="profile" className="flex flex-col gap-4" data-testid="account-profile">
208
- <Heading level={2}>{t("profile.heading")}</Heading>
209
- <DescriptionList
210
- items={[
211
- {
212
- label: t("profile.name"),
213
- value: name || t("profile.unavailable"),
214
- "data-testid": "account-profile-name",
215
- },
216
- {
217
- label: t("profile.email"),
218
- value: (
219
- <div className="inline-flex flex-wrap items-center gap-2">
220
- <div data-testid="account-profile-email">
221
- <Text as="span" variant="body-sm">
222
- {email}
223
- </Text>
224
- </div>
225
- <Badge
226
- variant={emailVerified ? "success" : "warning"}
227
- size="sm"
228
- role="status"
229
- data-testid="account-email-status"
230
- >
231
- {emailVerified ? t("profile.verified") : t("profile.unverified")}
232
- </Badge>
233
- </div>
234
- ),
235
- },
236
- ]}
99
+ <div className="max-w-3xl py-8" data-testid="account-inbox">
100
+ <Heading level={1} visual={2}>
101
+ {t("inbox.title")}
102
+ </Heading>
103
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
104
+ {t("inbox.lead")}
105
+ </Text>
106
+
107
+ {/* D6a — below `md` the For you stack sits between the header and the
108
+ list; absent when empty. From `md` up the same items are the
109
+ layout's aside (SignedInShell's breakpoint is "md"). */}
110
+ {forYou.items.length > 0 ? (
111
+ <div className="mt-block-loose md:hidden" data-testid="inbox-for-you">
112
+ <ForYouStack
113
+ items={forYou.items}
114
+ persistKey={ACCOUNT_PERSIST_KEY}
115
+ labels={forYouLabels(translate)}
116
+ locale={locale}
117
+ now={forYou.now}
237
118
  />
238
- <div>
239
- <SignOutButton label={t("profile.signOut")} homeHref={localeHref(locale, "/")} />
240
- </div>
241
- </section>
242
-
243
- <section id="results" className="flex flex-col gap-4" data-testid="account-results">
244
- <Heading level={2}>{t("results.heading")}</Heading>
245
- {signups.length === 0 ? (
246
- <EmptyState
247
- headline={t("results.emptyHeadline")}
248
- description={t("results.emptyDescription")}
249
- />
250
- ) : (
251
- <Table data-testid="account-results-table">
252
- <TableHeader>
253
- <TableRow>
254
- <TableHead scope="col">{t("results.experiment")}</TableHead>
255
- <TableHead scope="col">{t("results.waitlist")}</TableHead>
256
- <TableHead scope="col">{t("results.tier")}</TableHead>
257
- <TableHead scope="col">{t("results.score")}</TableHead>
258
- <TableHead scope="col">{t("results.completedAt")}</TableHead>
259
- </TableRow>
260
- </TableHeader>
261
- <TableBody>
262
- {signups.map((signup) => (
263
- <TableRow
264
- key={signup.experimentKey}
265
- data-testid={`account-result-${signup.experimentKey}`}
266
- >
267
- <TableCell>{signup.experimentKey}</TableCell>
268
- <TableCell>{signup.waitlistKey}</TableCell>
269
- <TableCell>{signup.tier ?? unavailable}</TableCell>
270
- <TableCell>
271
- {signup.score === null ? unavailable : formatCount(signup.score, locale)}
272
- </TableCell>
273
- <TableCell>{formatDateTime(signup.completedAt, locale)}</TableCell>
274
- </TableRow>
275
- ))}
276
- </TableBody>
277
- </Table>
278
- )}
279
- </section>
280
-
281
- <section id="purchases" className="flex flex-col gap-4" data-testid="account-purchases">
282
- <Heading level={2}>{t("purchases.heading")}</Heading>
283
- {checkouts.length === 0 ? (
284
- <EmptyState
285
- headline={t("purchases.emptyHeadline")}
286
- description={t("purchases.emptyDescription")}
287
- />
288
- ) : (
289
- <Table data-testid="account-purchases-table">
290
- <TableHeader>
291
- <TableRow>
292
- <TableHead scope="col">{t("purchases.offer")}</TableHead>
293
- <TableHead scope="col">{t("purchases.status")}</TableHead>
294
- <TableHead scope="col">{t("purchases.amount")}</TableHead>
295
- <TableHead scope="col">{t("purchases.period")}</TableHead>
296
- <TableHead scope="col">{t("purchases.date")}</TableHead>
297
- </TableRow>
298
- </TableHeader>
299
- <TableBody>
300
- {checkouts.map((checkout) => (
301
- <TableRow key={checkout.id} data-testid={`account-purchase-${checkout.id}`}>
302
- <TableCell>{checkout.offerKey}</TableCell>
303
- <TableCell>{checkout.status}</TableCell>
304
- <TableCell>
305
- <MoneyAmount
306
- amountMinor={checkout.amountMinor}
307
- currency={checkout.currency}
308
- locale={locale}
309
- />
310
- </TableCell>
311
- <TableCell>{checkout.period}</TableCell>
312
- <TableCell>
313
- {formatDateTime(checkout.completedAt ?? checkout.createdAt, locale)}
314
- </TableCell>
315
- </TableRow>
316
- ))}
317
- </TableBody>
318
- </Table>
319
- )}
320
- </section>
321
- </div>
322
- </AppShell>
119
+ </div>
120
+ ) : null}
121
+
122
+ {failed ? (
123
+ <ErrorRow
124
+ message={t("inbox.error")}
125
+ retryLabel={t("inbox.retry")}
126
+ className="mt-block-loose"
127
+ />
128
+ ) : null}
129
+
130
+ <InboxList
131
+ tabs={[
132
+ { id: "recents", label: t("inbox.tabs.recents"), count: recents.length, rows: recents },
133
+ { id: "suggested", label: t("inbox.tabs.suggested"), count: 0, rows: [] },
134
+ {
135
+ id: "scheduled",
136
+ label: t("inbox.tabs.scheduled"),
137
+ count: scheduled.length,
138
+ rows: scheduled,
139
+ },
140
+ { id: "all", label: t("inbox.tabs.all"), count: all.length, rows: all },
141
+ ]}
142
+ defaultTab="recents"
143
+ {...(failed ? {} : { emptyLine: t("inbox.empty") })}
144
+ />
145
+ </div>
323
146
  );
324
147
  }
@@ -0,0 +1,27 @@
1
+ "use client"; // Retry re-runs the page's server reads through the router
2
+
3
+ /**
4
+ * RetryButton — the Inbox error row's Retry control (#7430, spec 7423 D12).
5
+ *
6
+ * `router.refresh()` re-renders the route's Server Components, so every
7
+ * account read runs again without a full page load.
8
+ */
9
+
10
+ import { Button } from "@working-theory/ui/primitives";
11
+ import { useRouter } from "next/navigation";
12
+ import type { ReactElement } from "react";
13
+
14
+ export function RetryButton({ label }: { label: string }): ReactElement {
15
+ const router = useRouter();
16
+ return (
17
+ <Button
18
+ variant="secondary"
19
+ // 44px on a phone (a touch-first context), the 36px default from md up.
20
+ className="max-md:min-h-11"
21
+ onClick={() => router.refresh()}
22
+ data-testid="inbox-retry"
23
+ >
24
+ {label}
25
+ </Button>
26
+ );
27
+ }
@@ -0,0 +1,68 @@
1
+ "use client"; // reads and writes the theme cookie and the root's data-theme
2
+
3
+ /**
4
+ * ColorModeSetting — User settings › General's color mode (#7430, spec 7423
5
+ * D4; DESIGN.md §5.12a): Light · Auto · Dark as a `SegmentedControl` of
6
+ * three equal cells. The settings page renders it only when the site's
7
+ * theme defines more than one mode (operator fold-in 2026-10-01).
8
+ *
9
+ * The same cookie and the same `data-theme` contract the locale layout's
10
+ * flash-prevention script reads: `light` / `dark` pass through; `system`
11
+ * (Auto) follows `prefers-color-scheme`.
12
+ */
13
+
14
+ import { SegmentedControl } from "@working-theory/ui/primitives";
15
+ import { useEffect, useState, type ReactElement } from "react";
16
+
17
+ type Mode = "light" | "system" | "dark";
18
+
19
+ const COOKIE_NAME = process.env.NEXT_PUBLIC_THEME_COOKIE_NAME ?? "ailk-theme";
20
+ const ONE_YEAR = 60 * 60 * 24 * 365;
21
+
22
+ function readMode(): Mode {
23
+ const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
24
+ const value = match?.[1];
25
+ return value === "light" || value === "dark" ? value : "system";
26
+ }
27
+
28
+ function applyMode(mode: Mode): void {
29
+ document.cookie = `${COOKIE_NAME}=${mode}; path=/; max-age=${ONE_YEAR}; SameSite=Lax`;
30
+ const dark =
31
+ mode === "dark" ||
32
+ (mode === "system" &&
33
+ typeof window.matchMedia === "function" &&
34
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
35
+ document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
36
+ }
37
+
38
+ export function ColorModeSetting({
39
+ labels,
40
+ }: {
41
+ labels: { title: string; light: string; auto: string; dark: string };
42
+ }): ReactElement {
43
+ // Auto until mounted — the server cannot read the cookie's effect, and the
44
+ // first client render must match it.
45
+ const [mode, setMode] = useState<Mode>("system");
46
+ useEffect(() => {
47
+ setMode(readMode());
48
+ }, []);
49
+
50
+ return (
51
+ <SegmentedControl
52
+ aria-label={labels.title}
53
+ options={[
54
+ { value: "light", label: labels.light },
55
+ { value: "system", label: labels.auto },
56
+ { value: "dark", label: labels.dark },
57
+ ]}
58
+ value={mode}
59
+ onValueChange={(value) => {
60
+ const next = value as Mode;
61
+ setMode(next);
62
+ applyMode(next);
63
+ }}
64
+ touchTargetFloor
65
+ data-testid="account-color-mode"
66
+ />
67
+ );
68
+ }
@@ -0,0 +1,55 @@
1
+ "use client"; // submits through the account's Server Action and shows its outcome
2
+
3
+ /**
4
+ * DeletionRequest — User settings › Privacy's "Request account deletion"
5
+ * (#7430, spec 7423 D10). Files a Support request of kind `deletion`; an
6
+ * operator completes it (deletion needs an operator step today, so nothing
7
+ * is deleted on click).
8
+ */
9
+
10
+ import { Button, InlineMessage } from "@working-theory/ui/primitives";
11
+ import { useActionState, type ReactElement } from "react";
12
+
13
+ import { submitSupportRequest, type SupportRequestState } from "../support-actions";
14
+
15
+ export interface DeletionRequestLabels {
16
+ action: string;
17
+ subject: string;
18
+ message: string;
19
+ sent: string;
20
+ error: string;
21
+ }
22
+
23
+ export function DeletionRequest({ labels }: { labels: DeletionRequestLabels }): ReactElement {
24
+ const [state, formAction, pending] = useActionState<SupportRequestState, FormData>(
25
+ submitSupportRequest,
26
+ { kind: "idle" },
27
+ );
28
+
29
+ if (state.kind === "success") {
30
+ return (
31
+ <InlineMessage variant="success" role="status" className="text-body-sm">
32
+ {labels.sent}
33
+ </InlineMessage>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <form action={formAction} className="flex flex-col items-start gap-2">
39
+ <input type="hidden" name="kind" value="deletion" />
40
+ <input type="hidden" name="subject" value={labels.subject} />
41
+ <input type="hidden" name="message" value={labels.message} />
42
+ <Button
43
+ type="submit"
44
+ variant="secondary"
45
+ disabled={pending}
46
+ data-testid="account-deletion-request"
47
+ >
48
+ {labels.action}
49
+ </Button>
50
+ {state.kind === "error" ? (
51
+ <InlineMessage variant="error">{labels.error}</InlineMessage>
52
+ ) : null}
53
+ </form>
54
+ );
55
+ }