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.
- 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 +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- 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 +143 -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 +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- 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 +147 -21
- package/templates/database/CHANGELOG.md +7 -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,50 @@
|
|
|
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; 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|
|
62
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
|
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
|
-
);
|
|
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
|
-
<
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
}
|