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
@@ -1,65 +1,61 @@
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.
21
+ *
22
+ * #7469 (DESIGN.md §2b as amended 2026-10-01, matching the Admin's §5.15) —
23
+ * below `md` the route is the phone's two main tabs, one `PhoneTabPanel`
24
+ * each (the frame hands the tab over from `?tab=`):
25
+ * - Inbox — the filter tabs and the list only, in RowGroup's inset card.
26
+ * No For-you stack and no bottom sheet, and no lead on the phone.
27
+ * - For you — every For-you item as rows of one inset card, each with its
28
+ * type tag, title, Open or View, and ✕; the empty line when none waits,
29
+ * and D12's error row above the list when a For-you source failed.
30
+ * The h1 sits above both panels, visually hidden below `md` (the phone has
31
+ * no large title). At `md`+ only the Inbox panel shows, lead included.
32
+ *
33
+ * D12 — any read that fails (a kind or a For-you source) renders an error
34
+ * row with Retry above the list; the list then holds what DID load, never a
35
+ * silently empty Inbox.
21
36
  */
22
37
 
23
38
  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";
39
+ ForYouPanel,
40
+ InboxList,
41
+ PhoneTabPanel,
42
+ type InboxListRow,
43
+ } from "@working-theory/templates/account";
44
+ import { Heading, Text } from "@working-theory/ui/primitives";
45
+ import { ClipboardList, Receipt, type LucideProps } from "lucide-react";
50
46
  import type { Metadata } from "next";
51
- import { headers } from "next/headers";
52
47
  import { getTranslations } from "next-intl/server";
53
- import type { ReactElement } from "react";
48
+ import type { ComponentType, ReactElement } from "react";
54
49
 
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";
60
-
61
- import { openBillingPortal } from "./actions";
62
- import { SignOutButton } from "./sign-out-button";
50
+ import {
51
+ accountSession,
52
+ inboxItemHref,
53
+ loadForYou,
54
+ loadInboxItems,
55
+ type LoadedInboxItem,
56
+ } from "./account-data";
57
+ import { ErrorRow } from "./error-row";
58
+ import { ACCOUNT_PERSIST_KEY, forYouLabels } from "./for-you-labels";
63
59
 
64
60
  interface Props {
65
61
  params: Promise<{ locale: string }>;
@@ -68,257 +64,108 @@ interface Props {
68
64
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
69
65
  const { locale } = await params;
70
66
  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);
94
- }
95
-
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));
67
+ return { title: t("inbox.title"), description: t("description") };
100
68
  }
101
69
 
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
- }
70
+ /** The base kinds' row glyphs — a bare icon, never an icon square (§5.4). */
71
+ const KIND_ICON: Record<string, ComponentType<LucideProps>> = {
72
+ result: ClipboardList,
73
+ purchase: Receipt,
74
+ };
114
75
 
115
- export default async function AccountPage({ params }: Props): Promise<ReactElement> {
76
+ export default async function AccountInboxPage({ params }: Props): Promise<ReactElement> {
116
77
  const { locale } = await params;
117
78
  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
- );
79
+ const session = await accountSession(locale);
80
+
81
+ // The contract's translator, for keys that are data (a kind's `label`).
82
+ const translate = session?.ctx.t ?? ((key: string): string => key);
83
+ const [inbox, forYou] = session
84
+ ? await Promise.all([loadInboxItems(session.ctx), loadForYou(locale)])
85
+ : [{ items: [], failed: false }, { items: [], failed: false, now: "" }];
86
+
87
+ const dateFormat = new Intl.DateTimeFormat(locale, {
88
+ month: "short",
89
+ day: "numeric",
90
+ timeZone: "UTC",
91
+ });
92
+ const toRow = ({ kind, item }: LoadedInboxItem): InboxListRow => {
93
+ const label = translate(kind.label);
94
+ const icon = KIND_ICON[kind.kind];
95
+ return {
96
+ id: `${kind.kind}:${item.id}`,
97
+ ...(icon ? { icon } : {}),
98
+ caption: item.at ? `${label} · ${dateFormat.format(new Date(item.at))}` : label,
99
+ title: item.title,
100
+ href: inboxItemHref(locale, kind.kind, item.id),
101
+ };
102
+ };
103
+
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
+ const all = inbox.items.map(toRow);
107
+ const failed = inbox.failed || forYou.failed;
164
108
 
165
109
  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>
110
+ // The phone's top bar already clears the canvas — no top inset there.
111
+ <div className="max-w-3xl py-8 max-md:pt-0" data-testid="account-inbox">
112
+ {/* The page's one h1, on both phone tabs; visually hidden on the phone,
113
+ which has no large title (§5.15). */}
114
+ <Heading level={1} visual={2} className="max-md:sr-only">
115
+ {t("inbox.title")}
116
+ </Heading>
117
+ <PhoneTabPanel tabs={["inbox"]} desktop data-testid="inbox-panel-inbox">
118
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight hidden md:block">
119
+ {t("inbox.lead")}
120
+ </Text>
174
121
 
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
- }
122
+ {failed ? (
123
+ <ErrorRow
124
+ message={t("inbox.error")}
125
+ retryLabel={t("inbox.retry")}
126
+ className="mt-block-loose"
204
127
  />
205
128
  ) : null}
206
129
 
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
- ]}
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
+ </PhoneTabPanel>
146
+
147
+ {/* #7469 — the phone For you tab: every item, rows of one inset card.
148
+ At `md`+ the same items are the layout's aside. D12 here too: a
149
+ failed source shows the error row, and never the empty line alone. */}
150
+ <PhoneTabPanel tabs={["for-you"]} data-testid="inbox-panel-for-you">
151
+ {forYou.failed ? (
152
+ <ErrorRow
153
+ message={t("inbox.error")}
154
+ retryLabel={t("inbox.retry")}
155
+ className="mb-block-loose"
237
156
  />
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>
157
+ ) : null}
158
+ {forYou.failed && forYou.items.length === 0 ? null : (
159
+ <ForYouPanel
160
+ variant="list"
161
+ items={forYou.items}
162
+ persistKey={ACCOUNT_PERSIST_KEY}
163
+ labels={forYouLabels(translate)}
164
+ locale={locale}
165
+ now={forYou.now}
166
+ />
167
+ )}
168
+ </PhoneTabPanel>
169
+ </div>
323
170
  );
324
171
  }
@@ -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
+ }