Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions apps/dashboard/app/api/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import { assertPermission, PermissionDeniedError } from "@/lib/permissions";
import { IntegrationClient } from "@guildpass/integration-client";
import { getEnv, getApiMode } from "@/lib/env";
import { getMemberRepository } from "@/lib/repositories/factory";
import { filterMembers, paginateItems, parseListLimit, parseListPage } from "@/lib/pagination";
import type { MemberListQuery } from "@/lib/repositories/types";
import {
malformedPayloadError,
validateMemberCreatePayload,
validateMemberUpdatePayload,
} from "@/lib/validation/mutations";
import { recordDashboardActivity } from "@/lib/activity/dashboard";
import { isMemberRole } from "@/lib/member-roles";

export async function GET(request: Request): Promise<NextResponse> {
return handleApiError(async () => {
Expand All @@ -27,6 +30,7 @@ export async function GET(request: Request): Promise<NextResponse> {
const url = new URL(request.url);
const wallet = url.searchParams.get("wallet");
const discordUserId = url.searchParams.get("discordUserId");
const query = parseMemberListQuery(request);

if (apiMode === "live") {
const testClient = (globalThis as any).__TEST_INTEGRATION_CLIENT;
Expand Down Expand Up @@ -82,14 +86,40 @@ export async function GET(request: Request): Promise<NextResponse> {

try {
const memberRepository = getMemberRepository();
return apiResponse(await memberRepository.getAll());
return apiResponse(await memberRepository.query(query));
} catch (error) {
console.error("Error fetching members:", error);
return apiResponse(mockMembers as Member[]);
return apiResponse(getFallbackMembers(query));
}
});
}

const MEMBER_STATUSES: Member["status"][] = ["active", "inactive", "pending"];

function parseMemberListQuery(request: Request): MemberListQuery {
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
const role = searchParams.get("role");

return {
search: searchParams.get("search") ?? undefined,
status: isMemberStatus(status) ? status : "all",
role: role && isMemberRole(role) ? role : "all",
limit: parseListLimit(searchParams.get("limit")),
page: parseListPage(searchParams.get("page")),
cursor: searchParams.get("cursor"),
};
}

function isMemberStatus(value: string | null): value is Member["status"] {
return value !== null && MEMBER_STATUSES.includes(value as Member["status"]);
}

function getFallbackMembers(query: MemberListQuery) {
const filtered = filterMembers(mockMembers, query);
return paginateItems(filtered, query);
}

export async function POST(request: Request): Promise<NextResponse> {
let session;
try {
Expand Down
35 changes: 32 additions & 3 deletions apps/dashboard/app/api/passes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@ import { requireDashboardSession, UnauthorizedError } from "@/lib/auth/server-se
import { assertPermission, PermissionDeniedError } from "@/lib/permissions";
import { getApiMode } from "@/lib/env";
import { getPassRepository } from "@/lib/repositories/factory";
import type { PassListQuery } from "@/lib/repositories/types";
import { filterPasses, paginateItems, parseListLimit, parseListPage } from "@/lib/pagination";
import {
malformedPayloadError,
validatePassCreatePayload,
validatePassUpdatePayload,
} from "@/lib/validation/mutations";
import { recordDashboardActivity } from "@/lib/activity/dashboard";

export async function GET(): Promise<NextResponse> {
const PASS_STATUSES: Pass["status"][] = ["active", "inactive", "draft"];

export async function GET(
request: Request = new Request("http://localhost/api/passes")
): Promise<NextResponse> {
return handleApiError(async () => {
const apiMode = getApiMode();
const query = parsePassListQuery(request);

if (apiMode === "live") {
return apiUnsupported(
Expand All @@ -32,14 +39,36 @@ export async function GET(): Promise<NextResponse> {

try {
const passRepository = getPassRepository();
return await passRepository.getAll();
return await passRepository.query(query);
} catch (error) {
console.error("Error fetching passes:", error);
return mockPasses as Pass[];
return getFallbackPasses(query);
}
});
}

function parsePassListQuery(request: Request): PassListQuery {
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");

return {
search: searchParams.get("search") ?? undefined,
status: isPassStatus(status) ? status : "all",
limit: parseListLimit(searchParams.get("limit")),
page: parseListPage(searchParams.get("page")),
cursor: searchParams.get("cursor"),
};
}

function isPassStatus(value: string | null): value is Pass["status"] {
return value !== null && PASS_STATUSES.includes(value as Pass["status"]);
}

function getFallbackPasses(query: PassListQuery) {
const filtered = filterPasses(mockPasses, query);
return paginateItems(filtered, query);
}

export async function POST(request: Request): Promise<NextResponse> {
let session;
try {
Expand Down
173 changes: 76 additions & 97 deletions apps/dashboard/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,75 +1,72 @@
"use client";

import DashboardLayout from "@/components/DashboardLayout";
import LastUpdated from "@/components/LastUpdated";
import StatCard from "@/components/StatCard";
import StatusBadge from "@/components/StatusBadge";
import LastUpdated from "@/components/LastUpdated";
import UnsupportedBanner from "@/components/UnsupportedBanner";
import { ApiClientError, readApiResult } from "@/lib/api-client";
import { getClientApiMode } from "@/lib/client-env";
import { useActivityFeed } from "@/lib/hooks/useActivityFeed";
import { mockPasses, mockGuilds, mockMembers, type Member as MockMember } from "@/lib/mock-data";
import { readApiResult } from "@/lib/api-client";
import { mockGuilds, mockMembers, mockPasses, type Member as MockMember } from "@/lib/mock-data";
import type { PaginatedResult } from "@/lib/repositories/types";
import { useEffect, useState } from "react";
import { fetchList } from "@/lib/fetch-list";
import { getClientApiMode } from "@/lib/client-env";

type UnsupportedResource = "passes" | "guilds" | "members";

export default function DashboardPage() {
const { events, lastUpdated, refresh, refreshing } = useActivityFeed({ limit: 5 });
const { intervalMs } = getActivityRefreshConfig();
const apiMode = getClientApiMode();

const [passesCount, setPassesCount] = useState<number>(mockPasses.length);
const [guildsCount, setGuildsCount] = useState<number>(mockGuilds.length);
const [activeMembersCount, setActiveMembersCount] = useState<number>(
const [passesCount, setPassesCount] = useState(mockPasses.length);
const [guildsCount, setGuildsCount] = useState(mockGuilds.length);
const [activeMembersCount, setActiveMembersCount] = useState(
mockMembers.filter((m) => m.status === "active").length
);
const [unsupportedResources, setUnsupportedResources] = useState<
UnsupportedResource[]
>([]);
const [unsupportedResources, setUnsupportedResources] = useState<UnsupportedResource[]>([]);
const [hasError, setHasError] = useState(false);

const apiMode = getClientApiMode();

useEffect(() => {
let mounted = true;

async function load() {
const unsupported: UnsupportedResource[] = [];
let encounteredError = false;

try {
const [passesRes, guildsRes, membersRes] = await Promise.all([
fetch('/api/passes'),
fetch('/api/guilds'),
fetch('/api/members'),
fetch("/api/passes?limit=1"),
fetch("/api/guilds"),
fetch("/api/members?status=active&limit=1"),
]);

if (mounted) {
const [passes, guilds, members] = await Promise.all([
readApiResult<typeof mockPasses>(passesRes),
readApiResult<typeof mockGuilds>(guildsRes),
readApiResult<MockMember[]>(membersRes),
]);
const [passes, guilds, members] = await Promise.all([
readApiResult<PaginatedResult<(typeof mockPasses)[number]>>(passesRes),
readApiResult<typeof mockGuilds>(guildsRes),
readApiResult<PaginatedResult<MockMember>>(membersRes),
]);

setPassesCount(passes.length);
setGuildsCount(guilds.length);
setActiveMembersCount(members.filter((mm) => mm.status === 'active').length);
}
if (!mounted) return;
setPassesCount(passes.total);
setGuildsCount(guilds.length);
setActiveMembersCount(members.total);
} catch (err) {
console.warn('Dashboard stats fetch failed, using mock counts', err);
}
if (err instanceof ApiClientError && err.code === "UNSUPPORTED") {
unsupported.push("passes", "guilds", "members");
} else if (apiMode === "live") {
encounteredError = true;
}

// ── Members ─────────────────────────────────────────────
if (membersResult.ok) {
const members = membersResult.data as MockMember[];
setActiveMembersCount(
members.filter((m) => m.status === "active").length
);
} else if (membersResult.code === "UNSUPPORTED_IN_LIVE_MODE") {
unsupported.push("members");
} else if (apiMode === "live") {
encounteredError = true;
console.warn("Dashboard stats fetch failed, using mock counts", err);
}

setUnsupportedResources(unsupported);
setHasError(encounteredError);
if (mounted) {
setUnsupportedResources(unsupported);
setHasError(encounteredError);
}
}

load();
return () => {
mounted = false;
Expand All @@ -79,13 +76,12 @@ export default function DashboardPage() {
const allUnsupported =
unsupportedResources.length === 3 ||
(unsupportedResources.length > 0 &&
["passes", "guilds", "members"].every((r) =>
unsupportedResources.includes(r as UnsupportedResource)
["passes", "guilds", "members"].every((resource) =>
unsupportedResources.includes(resource as UnsupportedResource)
));

return (
<DashboardLayout title="Dashboard">
{/* ── Unsupported banner (live mode, no list endpoints) ──── */}
{allUnsupported && (
<UnsupportedBanner
resource="dashboard"
Expand All @@ -101,89 +97,66 @@ export default function DashboardPage() {
)}

{hasError && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 my-4">
<div className="my-4 rounded-xl border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">
Some dashboard stats failed to load. Check the API configuration.
</p>
</div>
)}

{/* ── Stat cards ──────────────────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard title="Total Passes" value={passesCount} icon="🎫" trend="+2 this week" />
<StatCard title="Active Guilds" value={guildsCount} icon="🏰" trend="+1 this week" />
<StatCard title="Active Members" value={activeMembersCount} icon="👥" trend="+12 this week" />
<StatCard title="Total Activity" value={events.length} icon="📋" trend="live" />
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCard title="Total Passes" value={passesCount} icon="P" trend="+2 this week" />
<StatCard title="Active Guilds" value={guildsCount} icon="G" trend="+1 this week" />
<StatCard title="Active Members" value={activeMembersCount} icon="M" trend="+12 this week" />
<StatCard title="Total Activity" value={events.length} icon="A" trend="live" />
</div>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* ── Live recent activity ────────────────────────────────── */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-slate-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-800">Recent Activity</h2>
<LastUpdated date={lastUpdated} autoRefresh={intervalMs > 0} />
</div>
<button
onClick={refresh}
disabled={refreshing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-500 bg-white border border-slate-200 rounded-md hover:bg-slate-50 hover:border-slate-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
title="Refresh recent activity"
>
<svg
className={`w-3.5 h-3.5 ${refreshing ? "animate-spin" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"
/>
</svg>
{refreshing ? "…" : "Refresh"}
{refreshing ? "Refreshing" : "Refresh"}
</button>
</div>
{unsupportedResources.includes("passes") ? (
<div className="text-center py-8 text-sm text-amber-600">
Activity is tracked via webhooks and is independent of list endpoints.
</div>
) : (
<ul className="space-y-4">
{events.slice(0, 5).map((activity) => (
<li
key={activity.id}
className="flex items-start gap-4 border-b border-slate-100 pb-3 last:border-0"
>
<div className="w-2 h-2 rounded-full bg-primary-500 mt-2 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-slate-800 truncate">{activity.description}</p>
<p className="text-xs text-slate-500 mt-0.5">
{new Date(activity.timestamp).toLocaleString()}
</p>
</div>
</li>
))}
</ul>
)}
<ul className="space-y-4">
{events.slice(0, 5).map((activity) => (
<li key={activity.id} className="flex items-start gap-4 border-b border-slate-100 pb-3 last:border-0">
<div className="mt-2 h-2 w-2 shrink-0 rounded-full bg-primary-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-slate-800">{activity.description}</p>
<p className="mt-0.5 text-xs text-slate-500">
{new Date(activity.timestamp).toLocaleString()}
</p>
</div>
</li>
))}
</ul>
</div>

{/* ── Recent passes (static) ──────────────────────────────── */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h2 className="text-xl font-semibold text-slate-800 mb-4">Recent Passes</h2>
<div className="rounded-xl border border-slate-200 bg-white p-6">
<h2 className="mb-4 text-xl font-semibold text-slate-800">Recent Passes</h2>
{unsupportedResources.includes("passes") ? (
<div className="text-center py-8 text-sm text-amber-600">
<div className="py-8 text-center text-sm text-amber-600">
Pass listing is not available in live mode.
</div>
) : (
<ul className="space-y-3">
{mockPasses.slice(0, 4).map((pass) => (
<li key={pass.id} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<li key={pass.id} className="flex items-center justify-between rounded-lg bg-slate-50 p-3">
<div>
<p className="font-medium text-slate-800">{pass.name}</p>
<p className="text-sm text-slate-500">{pass.currentSupply} / {pass.maxSupply ?? "∞"}</p>
<p className="text-sm text-slate-500">
{pass.currentSupply} / {pass.maxSupply ?? "unlimited"}
</p>
</div>
<StatusBadge status={pass.status} />
</li>
Expand All @@ -195,3 +168,9 @@ export default function DashboardPage() {
</DashboardLayout>
);
}

function getActivityRefreshConfig() {
const raw = process.env.NEXT_PUBLIC_ACTIVITY_REFRESH_MS;
const parsed = raw ? Number(raw) : 0;
return { intervalMs: Number.isFinite(parsed) && parsed > 0 ? parsed : 0 };
}
Loading