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
|
@@ -1,65 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /[locale]/account — the
|
|
3
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `@working-theory/
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
type
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} from "
|
|
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 {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
import {
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
{
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
}
|