Skip to content

Commit b10ff96

Browse files
hotfix: fix 405 error on Vercel login redirect (use 303 status)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 198eb7d commit b10ff96

7 files changed

Lines changed: 293 additions & 5 deletions

File tree

apps/dashboard/src/app/api/auth/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
1515
const password = formData.get('password');
1616

1717
if (typeof password !== 'string' || password !== secret) {
18-
return NextResponse.redirect(new URL('/login?error=1', request.url));
18+
return NextResponse.redirect(new URL('/login?error=1', request.url), 303);
1919
}
2020

2121
const cookieValue = await getAuthCookieValue();
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
2626
);
2727
}
2828

29-
const response = NextResponse.redirect(new URL('/', request.url));
29+
const response = NextResponse.redirect(new URL('/', request.url), 303);
3030
response.cookies.set('dashboard_auth', cookieValue, {
3131
httpOnly: true,
3232
sameSite: 'lax',
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
import { resolveEnsNames } from '../../../../lib/ens';
4+
import { getTopFollowedAddresses } from '../../../../lib/metrics';
5+
6+
export const revalidate = 30;
7+
8+
export async function GET(request: NextRequest) {
9+
const limitParam = request.nextUrl.searchParams.get('limit');
10+
const limit = limitParam ? Math.min(Math.max(Number(limitParam), 1), 50) : 10;
11+
12+
try {
13+
const items = await getTopFollowedAddresses(limit);
14+
const addresses = items.map((item) => item.address);
15+
const { map: ensMap, available: ensAvailable } = await resolveEnsNames(addresses);
16+
17+
const itemsWithEns = items.map((item) => ({
18+
...item,
19+
ens: ensMap[item.address] ?? null,
20+
}));
21+
22+
return NextResponse.json({ items: itemsWithEns, ensAvailable });
23+
} catch (error) {
24+
console.error('Failed to load top followed addresses:', error);
25+
return NextResponse.json({ error: 'Failed to load top followed addresses.' }, { status: 500 });
26+
}
27+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { getUsersByDaoCount } from '../../../../lib/metrics';
4+
5+
export const revalidate = 30;
6+
7+
export async function GET() {
8+
try {
9+
const items = await getUsersByDaoCount();
10+
return NextResponse.json({ items });
11+
} catch (error) {
12+
console.error('Failed to load users by DAO count:', error);
13+
return NextResponse.json({ error: 'Failed to load users by DAO count.' }, { status: 500 });
14+
}
15+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import {
5+
BarChart,
6+
Bar,
7+
XAxis,
8+
YAxis,
9+
CartesianGrid,
10+
Tooltip,
11+
ResponsiveContainer,
12+
} from 'recharts';
13+
14+
type TopFollowedAccount = {
15+
address: string;
16+
followerCount: number;
17+
ens: string | null;
18+
};
19+
20+
type TopFollowedAccountsCardProps = {
21+
data: TopFollowedAccount[];
22+
};
23+
24+
function truncateAddress(address: string) {
25+
return `${address.slice(0, 6)}...${address.slice(-4)}`;
26+
}
27+
28+
function CopyableAddress({ address, ens }: { address: string; ens: string | null }) {
29+
const [copied, setCopied] = useState(false);
30+
31+
async function handleCopy() {
32+
try {
33+
await navigator.clipboard.writeText(address);
34+
setCopied(true);
35+
setTimeout(() => setCopied(false), 1500);
36+
} catch {
37+
// clipboard API may not be available
38+
}
39+
}
40+
41+
return (
42+
<button
43+
onClick={handleCopy}
44+
title={`Copy ${address}`}
45+
className="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs text-muted hover:bg-white/5 hover:text-text transition-colors"
46+
>
47+
<span className="font-mono">{ens ?? truncateAddress(address)}</span>
48+
<span className="text-[10px] opacity-60">
49+
{copied ? '✓' : '⧉'}
50+
</span>
51+
</button>
52+
);
53+
}
54+
55+
export default function TopFollowedAccountsCard({ data }: TopFollowedAccountsCardProps) {
56+
const chartData = data.map((item) => ({
57+
...item,
58+
label: item.ens ?? truncateAddress(item.address),
59+
}));
60+
61+
return (
62+
<div className="rounded-xl border border-border bg-panel p-4 shadow-sm">
63+
<h3 className="text-sm font-semibold text-text">Top followed accounts</h3>
64+
<div className="mt-4 h-60">
65+
<ResponsiveContainer width="100%" height="100%">
66+
<BarChart data={chartData}>
67+
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
68+
<XAxis
69+
dataKey="label"
70+
tick={{ fill: '#cbd5f5', fontSize: 12 }}
71+
axisLine={{ stroke: '#334155' }}
72+
tickLine={{ stroke: '#334155' }}
73+
interval={0}
74+
angle={-45}
75+
textAnchor="end"
76+
height={60}
77+
/>
78+
<YAxis
79+
tick={{ fill: '#cbd5f5', fontSize: 12 }}
80+
axisLine={{ stroke: '#334155' }}
81+
tickLine={{ stroke: '#334155' }}
82+
/>
83+
<Tooltip
84+
contentStyle={{
85+
backgroundColor: '#1e293b',
86+
border: '1px solid #475569',
87+
borderRadius: '6px',
88+
}}
89+
labelStyle={{ color: '#f1f5f9', fontWeight: 600 }}
90+
itemStyle={{ color: '#94a3b8' }}
91+
labelFormatter={(_label, payload) => {
92+
if (!payload?.[0]) return _label;
93+
const item = payload[0].payload as (typeof chartData)[number];
94+
return item.ens ? `${item.ens} (${truncateAddress(item.address)})` : item.address;
95+
}}
96+
formatter={(value: number) => [value, 'Followers']}
97+
/>
98+
<Bar dataKey="followerCount" fill="#38bdf8" radius={[4, 4, 0, 0]} />
99+
</BarChart>
100+
</ResponsiveContainer>
101+
</div>
102+
{data.length > 0 && (
103+
<div className="mt-3 flex flex-wrap gap-1">
104+
{data.map((item) => (
105+
<CopyableAddress key={item.address} address={item.address} ens={item.ens} />
106+
))}
107+
</div>
108+
)}
109+
</div>
110+
);
111+
}

apps/dashboard/src/components/dashboard-client.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import MetricCard from './metric-card';
66
import BarChartCard from './charts/bar-chart-card';
77
import LineChartCard from './charts/line-chart-card';
88
import PieChartCard from './charts/pie-chart-card';
9+
import TopFollowedAccountsCard from './charts/top-followed-accounts-card';
910
import UsersTable from './tables/users-table';
1011

1112
type SummaryTotals = {
@@ -69,12 +70,34 @@ type NotificationActivityResponse = {
6970
points: NotificationPoint[];
7071
};
7172

73+
type TopFollowedAddress = {
74+
address: string;
75+
followerCount: number;
76+
ens: string | null;
77+
};
78+
79+
type TopFollowedAddressResponse = {
80+
items: TopFollowedAddress[];
81+
ensAvailable: boolean;
82+
};
83+
84+
type UsersByDaoCount = {
85+
daoCount: number;
86+
userCount: number;
87+
};
88+
89+
type UsersByDaoCountResponse = {
90+
items: UsersByDaoCount[];
91+
};
92+
7293
export default function DashboardClient() {
7394
const [summary, setSummary] = useState<SummaryResponse | null>(null);
7495
const [growth, setGrowth] = useState<GrowthPoint[]>([]);
7596
const [daos, setDaos] = useState<DaoItem[]>([]);
7697
const [daoNotifications, setDaoNotifications] = useState<DaoNotificationItem[]>([]);
7798
const [notificationActivity, setNotificationActivity] = useState<NotificationPoint[]>([]);
99+
const [topAddresses, setTopAddresses] = useState<TopFollowedAddress[]>([]);
100+
const [usersByDaoCount, setUsersByDaoCount] = useState<UsersByDaoCount[]>([]);
78101
const [notificationDaoFilter, setNotificationDaoFilter] = useState('');
79102
const [loading, setLoading] = useState(true);
80103
const [error, setError] = useState<string | null>(null);
@@ -86,20 +109,24 @@ export default function DashboardClient() {
86109
setLoading(true);
87110
setError(null);
88111
try {
89-
const [summaryRes, growthRes, daosRes, notificationsRes, activityRes] = await Promise.all([
112+
const [summaryRes, growthRes, daosRes, notificationsRes, activityRes, topAddressesRes, usersByDaoCountRes] = await Promise.all([
90113
fetch('/api/metrics/summary'),
91114
fetch('/api/metrics/growth'),
92115
fetch('/api/metrics/daos'),
93116
fetch('/api/metrics/notifications-by-dao'),
94117
fetch('/api/metrics/notification-activity'),
118+
fetch('/api/metrics/top-addresses'),
119+
fetch('/api/metrics/users-by-dao-count'),
95120
]);
96121

97122
if (
98123
!summaryRes.ok ||
99124
!growthRes.ok ||
100125
!daosRes.ok ||
101126
!notificationsRes.ok ||
102-
!activityRes.ok
127+
!activityRes.ok ||
128+
!topAddressesRes.ok ||
129+
!usersByDaoCountRes.ok
103130
) {
104131
throw new Error('Failed to load dashboard metrics.');
105132
}
@@ -109,13 +136,17 @@ export default function DashboardClient() {
109136
const daosJson = (await daosRes.json()) as DaoResponse;
110137
const notificationsJson = (await notificationsRes.json()) as DaoNotificationResponse;
111138
const activityJson = (await activityRes.json()) as NotificationActivityResponse;
139+
const topAddressesJson = (await topAddressesRes.json()) as TopFollowedAddressResponse;
140+
const usersByDaoCountJson = (await usersByDaoCountRes.json()) as UsersByDaoCountResponse;
112141

113142
if (!cancelled) {
114143
setSummary(summaryJson);
115144
setGrowth(growthJson.points ?? []);
116145
setDaos(daosJson.items ?? []);
117146
setDaoNotifications(notificationsJson.items ?? []);
118147
setNotificationActivity(activityJson.points ?? summaryJson.notificationActivity ?? []);
148+
setTopAddresses(topAddressesJson.items ?? []);
149+
setUsersByDaoCount(usersByDaoCountJson.items ?? []);
119150
}
120151
} catch (err) {
121152
if (!cancelled) {
@@ -234,7 +265,7 @@ export default function DashboardClient() {
234265
</div>
235266
</section>
236267

237-
<section className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
268+
<section className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
238269
<BarChartCard
239270
title="Top DAOs by subscribers"
240271
data={daoChart}
@@ -264,6 +295,17 @@ export default function DashboardClient() {
264295
barKey="userCount"
265296
valueLabel="Users"
266297
/>
298+
<TopFollowedAccountsCard data={topAddresses} />
299+
<BarChartCard
300+
title="DAOs tracked per user"
301+
data={usersByDaoCount.map((item) => ({
302+
...item,
303+
label: String(item.daoCount),
304+
}))}
305+
xKey="label"
306+
barKey="userCount"
307+
valueLabel="Users"
308+
/>
267309
</section>
268310

269311
<section className="flex flex-col gap-3">

apps/dashboard/src/lib/metrics.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
buildEngagementDistributionQuery,
66
buildGrowthSeries,
77
buildNotificationActivityByDaoQuery,
8+
buildTopFollowedAddressesQuery,
9+
buildUsersByDaoCountQuery,
810
buildUsersQueries,
911
} from './metrics';
1012

@@ -70,6 +72,27 @@ test('buildEngagementDistributionQuery groups by coalesced address count', () =>
7072
assert.match(text, /ORDER BY COALESCE\(addr\.address_count, 0\)/);
7173
});
7274

75+
test('buildTopFollowedAddressesQuery groups by address and limits results', () => {
76+
const { text, values } = buildTopFollowedAddressesQuery(5);
77+
78+
assert.match(text, /FROM user_addresses/);
79+
assert.match(text, /COUNT\(DISTINCT ua\.user_id\)/);
80+
assert.match(text, /LOWER\(ua\.address\)/);
81+
assert.match(text, /ORDER BY follower_count DESC/);
82+
assert.match(text, /LIMIT \$1/);
83+
assert.equal(values[0], 5);
84+
});
85+
86+
test('buildUsersByDaoCountQuery counts active preferences per user', () => {
87+
const text = buildUsersByDaoCountQuery();
88+
89+
assert.match(text, /FROM users u/);
90+
assert.match(text, /LEFT JOIN user_preferences p ON p\.user_id = u\.id AND p\.is_active = true/);
91+
assert.match(text, /COUNT\(p\.id\) as dao_count/);
92+
assert.match(text, /GROUP BY dao_count/);
93+
assert.match(text, /ORDER BY dao_count/);
94+
});
95+
7396
test('buildGrowthSeries fills missing days with zero counts', () => {
7497
const rows = [
7598
{ day: '2024-01-01T00:00:00.000Z', count: '2' },

0 commit comments

Comments
 (0)