Skip to content

Commit 185bdde

Browse files
authored
[Dashboard] Redefine the user page with tabs and updated UI (#1351)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Tabbed user profile with Activity (30-day analytics, KPIs, daily chart, top lists, recent events), Payments (transactions, subscriptions, product/item balances) and an activity heatmap sidebar. * New internal user-activity API and admin-facing activity hook; admin API client can fetch per-user activity. * **UI/UX Improvements** * Unified menus, cards and tables; inline editable user details with accept/revert; metadata editor validates JSON; country-code input has draft editing; tabs support optional icons. * **API** * Transactions endpoint and admin transaction queries now support optional customer-scoped filtering. * **Tests** * End-to-end coverage for the user-activity endpoint. <!-- end of auto-generated comment: release notes by coderabbit.ai --> <img width="1326" height="752" alt="image" src="https://github.com/user-attachments/assets/97c04dca-db59-4357-98b1-8eae5a7a3673" /> <img width="1142" height="251" alt="image" src="https://github.com/user-attachments/assets/e1aa44fc-0d7e-436d-90a5-c7cb15155e24" /> <img width="1170" height="1125" alt="image" src="https://github.com/user-attachments/assets/bf6659fd-a9b5-4ae6-a13d-dab9956ad650" />
1 parent 7a54e82 commit 185bdde

18 files changed

Lines changed: 2698 additions & 665 deletions

File tree

apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@ async function getTransactions(options: {
594594
cursor: string | undefined,
595595
type: TransactionType | undefined,
596596
customerType: "user" | "team" | "custom" | undefined,
597+
customerId: string | undefined,
597598
}): Promise<{ transactions: Transaction[], nextCursor: string | null }> {
598599
const ledgerTypes = getLedgerTypesForFilter(options.type);
599600
if (ledgerTypes.length === 0) {
@@ -616,6 +617,9 @@ async function getTransactions(options: {
616617
if (options.customerType) {
617618
whereClauses.push(`"__rows"."rowdata"->>'customerType' = ${quoteSqlStringLiteral(options.customerType).sql}`);
618619
}
620+
if (options.customerId) {
621+
whereClauses.push(`"__rows"."rowdata"->>'customerId' = ${quoteSqlStringLiteral(options.customerId).sql}`);
622+
}
619623
if (decodedCursor) {
620624
whereClauses.push(`(
621625
(("__rows"."rowdata"->>'createdAtMillis')::bigint < ${decodedCursor.createdAtMillis})
@@ -673,6 +677,9 @@ async function getTransactions(options: {
673677
if (options.customerType) {
674678
refundWhereClauses.push(`"__rows"."rowdata"->>'customerType' = ${quoteSqlStringLiteral(options.customerType).sql}`);
675679
}
680+
if (options.customerId) {
681+
refundWhereClauses.push(`"__rows"."rowdata"->>'customerId' = ${quoteSqlStringLiteral(options.customerId).sql}`);
682+
}
676683
const refundSql = `
677684
SELECT "__rows"."rowdata" AS "rowData"
678685
FROM (${baseSql}) AS "__rows"
@@ -729,6 +736,7 @@ export const GET = createSmartRouteHandler({
729736
limit: yupString().optional(),
730737
type: yupString().oneOf(TRANSACTION_TYPES).optional(),
731738
customer_type: yupString().oneOf(['user', 'team', 'custom']).optional(),
739+
customer_id: yupString().optional(),
732740
}).optional(),
733741
}),
734742
response: yupObject({
@@ -751,6 +759,7 @@ export const GET = createSmartRouteHandler({
751759
cursor: query.cursor,
752760
type: query.type,
753761
customerType: query.customer_type,
762+
customerId: query.customer_id,
754763
});
755764

756765
return {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { getClickhouseAdminClient } from "@/lib/clickhouse";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { ClickHouseError } from "@clickhouse/client";
4+
import { UserActivityResponseBodySchema } from "@stackframe/stack-shared/dist/interface/admin-metrics";
5+
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6+
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
7+
8+
// Per-user activity heatmap window. Sized to match the 22×16 dashboard grid
9+
// so every cell maps to exactly one day and we never truncate or pad awkwardly
10+
// on the client. Bump both sides if you want a longer/shorter window.
11+
const USER_ACTIVITY_WINDOW_DAYS = 22 * 16;
12+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
13+
14+
function formatClickhouseDateTimeParam(date: Date): string {
15+
// ClickHouse DateTime params are passed as "YYYY-MM-DDTHH:MM:SS" (no timezone); treat them as UTC.
16+
return date.toISOString().slice(0, 19);
17+
}
18+
19+
export const GET = createSmartRouteHandler({
20+
metadata: {
21+
hidden: true,
22+
},
23+
request: yupObject({
24+
auth: yupObject({
25+
type: adminAuthTypeSchema.defined(),
26+
tenancy: adaptSchema.defined(),
27+
}),
28+
query: yupObject({
29+
user_id: yupString().defined(),
30+
}).defined(),
31+
}),
32+
response: yupObject({
33+
statusCode: yupNumber().oneOf([200]).defined(),
34+
bodyType: yupString().oneOf(["json"]).defined(),
35+
body: UserActivityResponseBodySchema,
36+
}),
37+
handler: async (req) => {
38+
const { tenancy } = req.auth;
39+
const userId = req.query.user_id;
40+
41+
const now = new Date();
42+
const todayUtc = new Date(now);
43+
todayUtc.setUTCHours(0, 0, 0, 0);
44+
const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS);
45+
const since = new Date(todayUtc.getTime() - (USER_ACTIVITY_WINDOW_DAYS - 1) * ONE_DAY_MS);
46+
47+
let rows: { day: string, activity: string | number }[];
48+
try {
49+
const clickhouseClient = getClickhouseAdminClient();
50+
const result = await clickhouseClient.query({
51+
query: `
52+
SELECT
53+
toDate(event_at) AS day,
54+
count() AS activity
55+
FROM analytics_internal.events
56+
WHERE project_id = {projectId:String}
57+
AND branch_id = {branchId:String}
58+
AND user_id = {userId:String}
59+
AND event_at >= {since:DateTime}
60+
AND event_at < {untilExclusive:DateTime}
61+
GROUP BY day
62+
ORDER BY day ASC
63+
`,
64+
query_params: {
65+
projectId: tenancy.project.id,
66+
branchId: tenancy.branchId,
67+
userId,
68+
since: formatClickhouseDateTimeParam(since),
69+
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
70+
},
71+
format: "JSONEachRow",
72+
});
73+
rows = await result.json();
74+
} catch (error) {
75+
if (!(error instanceof ClickHouseError)) {
76+
throw error;
77+
}
78+
captureError("internal-user-activity-clickhouse-fallback", new StackAssertionError(
79+
"Failed to load user activity due to ClickHouse query failure.",
80+
{
81+
cause: error,
82+
projectId: tenancy.project.id,
83+
branchId: tenancy.branchId,
84+
userId,
85+
},
86+
));
87+
throw new StatusError(StatusError.ServiceUnavailable, "Analytics activity is temporarily unavailable.");
88+
}
89+
90+
const byDay = new Map<string, number>();
91+
for (const row of rows) {
92+
// ClickHouse returns dates/datetimes without timezone, treat as UTC.
93+
const dayKey = row.day.split("T")[0];
94+
byDay.set(dayKey, Number(row.activity));
95+
}
96+
97+
const dataPoints: { date: string, activity: number }[] = [];
98+
for (let i = 0; i < USER_ACTIVITY_WINDOW_DAYS; i += 1) {
99+
const day = new Date(since.getTime() + i * ONE_DAY_MS);
100+
const dayKey = day.toISOString().split("T")[0];
101+
dataPoints.push({ date: dayKey, activity: byDay.get(dayKey) ?? 0 });
102+
}
103+
104+
return {
105+
statusCode: 200,
106+
bodyType: "json",
107+
body: { data_points: dataPoints },
108+
};
109+
},
110+
});

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@ function ComponentDemo({
6262
title,
6363
description,
6464
children,
65+
className,
6566
}: {
6667
title: string,
6768
description?: string,
6869
children: React.ReactNode,
70+
className?: string,
6971
}) {
7072
return (
71-
<div className="space-y-4">
73+
<div className={cn("space-y-4", className)}>
7274
<div className="flex items-start justify-between gap-4">
7375
<div>
7476
<Typography type="h3" className="text-lg font-semibold">{title}</Typography>
@@ -1119,6 +1121,25 @@ export default function PageClient() {
11191121
/>
11201122
</ComponentDemo>
11211123

1124+
<ComponentDemo
1125+
title="Category tabs with trailing slot"
1126+
description="Use `trailing` for actions that live in the tab bar but are not a tab (e.g. “Install apps”)."
1127+
className="pt-6"
1128+
>
1129+
<DesignCategoryTabs
1130+
categories={categories}
1131+
selectedCategory={selectedCategory}
1132+
onSelect={setSelectedCategory}
1133+
glassmorphic={false}
1134+
gradient="blue"
1135+
trailing={(
1136+
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">
1137+
+ Example action
1138+
</span>
1139+
)}
1140+
/>
1141+
</ComponentDemo>
1142+
11221143
<div className="pt-4 border-t border-black/[0.12] dark:border-white/[0.06]">
11231144
<Typography type="label" className="font-semibold mb-3">Props</Typography>
11241145
<PropsTable props={[
@@ -1129,6 +1150,7 @@ export default function PageClient() {
11291150
{ name: "size", type: "'sm' | 'md'", default: "'sm'", description: "Controls padding and density." },
11301151
{ name: "glassmorphic", type: "boolean", default: "false", description: "Enable when tabs are on glass surfaces." },
11311152
{ name: "gradient", type: "'blue' | 'cyan' | 'purple' | 'green' | 'orange' | 'default'", description: "Optional accent when glassmorphic is true." },
1153+
{ name: "trailing", type: "ReactNode", description: "Renders at the end of the tab bar, after all tab buttons (not a tab)." },
11321154
]} />
11331155
</div>
11341156
</DesignSection>

0 commit comments

Comments
 (0)