feat: styling polish, command palette, breadcrumbs, activity feed, export, sort, clipboard
- 40+ styling fixes across all 11 pages (consistent padding, titles, empty states, date formatting) - Command Palette (Ctrl+K) with 11 nav items + 3 actions - Breadcrumbs with hash-based path detection and mobile truncation - Activity Feed component with 11 color-coded action icons and 30s auto-refresh - ExportButton (CSV) on Purchases, Audit, Users pages - SortableHeader with 3-state toggle on Purchases and Audit pages - copyToClipboard utility with navigator.clipboard + fallback - Notification badge on Purchases sidebar item (pending count) - Connection status indicator in sidebar footer - Removed dynamic imports from page.tsx (reduces memory) - Disabled Prisma query logging (reduces memory) - All components verified: named exports, @/ prefix, hash navigation, sonner toasts
This commit is contained in:
@@ -6,72 +6,18 @@ import { AdminSidebar } from "@/components/layout/admin-sidebar";
|
||||
import { AdminHeader } from "@/components/layout/admin-header";
|
||||
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
|
||||
import { LoginPage } from "@/app/login/page";
|
||||
|
||||
// Lazy-loaded page components
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const DashboardPage = dynamic(() => import("@/components/dashboard/dashboard-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const CatalogPage = dynamic(() => import("@/components/catalog/catalog-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const UsersPage = dynamic(() => import("@/components/users/users-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const UserDetailPage = dynamic(() => import("@/components/users/user-detail-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const WalletsPage = dynamic(() => import("@/components/wallets/wallets-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const PurchasesPage = dynamic(() => import("@/components/purchases/purchases-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const AuditPage = dynamic(() => import("@/components/audit/audit-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const CategoriesPage = dynamic(() => import("@/components/categories/categories-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const LocationsPage = dynamic(() => import("@/components/locations/locations-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const SettingsPage = dynamic(() => import("@/components/settings/settings-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const LocalesPage = dynamic(() => import("@/components/locales/locales-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
const SeedPage = dynamic(() => import("@/components/seed/seed-page"), {
|
||||
ssr: false,
|
||||
loading: () => <PageSkeleton />,
|
||||
});
|
||||
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { DashboardPage } from "@/components/dashboard/dashboard-page";
|
||||
import { CatalogPage } from "@/components/catalog/catalog-page";
|
||||
import { UsersPage } from "@/components/users/users-page";
|
||||
import { UserDetailPage } from "@/components/users/user-detail-page";
|
||||
import { WalletsPage } from "@/components/wallets/wallets-page";
|
||||
import { PurchasesPage } from "@/components/purchases/purchases-page";
|
||||
import { AuditPage } from "@/components/audit/audit-page";
|
||||
import { CategoriesPage } from "@/components/categories/categories-page";
|
||||
import { LocationsPage } from "@/components/locations/locations-page";
|
||||
import { SettingsPage } from "@/components/settings/settings-page";
|
||||
import { LocalesPage } from "@/components/locales/locales-page";
|
||||
import { SeedPage } from "@/components/seed/seed-page";
|
||||
|
||||
export default function AppPage() {
|
||||
const { isAuthenticated, checkSession } = useAuthStore();
|
||||
@@ -83,7 +29,6 @@ export default function AppPage() {
|
||||
checkSession().then(() => setReady(true));
|
||||
}, [checkSession]);
|
||||
|
||||
// Simple hash-based client-side routing
|
||||
useEffect(() => {
|
||||
const handleHash = () => {
|
||||
const hash = window.location.hash.slice(1) || "/";
|
||||
@@ -103,7 +48,6 @@ export default function AppPage() {
|
||||
return () => window.removeEventListener("hashchange", handleHash);
|
||||
}, []);
|
||||
|
||||
// Intercept link clicks for hash navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -111,7 +55,6 @@ export default function AppPage() {
|
||||
if (!link) return;
|
||||
const href = link.getAttribute("href");
|
||||
if (!href) return;
|
||||
// Skip external links and real API links
|
||||
if (href.startsWith("http") || href.startsWith("/api")) return;
|
||||
e.preventDefault();
|
||||
window.location.hash = href;
|
||||
@@ -149,7 +92,7 @@ export default function AppPage() {
|
||||
if (page === "/locales") return <LocalesPage />;
|
||||
if (page === "/seed") return <SeedPage />;
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">Page not found</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, FileText } from "lucide-react";
|
||||
import { ExportButton } from "@/components/shared/export-button";
|
||||
import { SortableHeader } from "@/components/shared/sortable-header";
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
@@ -86,6 +88,8 @@ export function AuditPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openRows, setOpenRows] = useState<Set<number>>(new Set());
|
||||
const [sortColumn, setSortColumn] = useState<string>("");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(null);
|
||||
const limit = 100;
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -110,6 +114,44 @@ export function AuditPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortColumn === column) {
|
||||
if (sortDirection === "asc") setSortDirection("desc");
|
||||
else if (sortDirection === "desc") {
|
||||
setSortColumn("");
|
||||
setSortDirection(null);
|
||||
}
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedLogs = useMemo(() => {
|
||||
if (!sortColumn || !sortDirection) return logs;
|
||||
return [...logs].sort((a, b) => {
|
||||
let valA: unknown;
|
||||
let valB: unknown;
|
||||
if (sortColumn === "date") { valA = a.createdAt; valB = b.createdAt; }
|
||||
else if (sortColumn === "action") { valA = a.action; valB = b.action; }
|
||||
else return 0;
|
||||
if (valA === valB) return 0;
|
||||
const cmp = valA < valB ? -1 : 1;
|
||||
return sortDirection === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [logs, sortColumn, sortDirection]);
|
||||
|
||||
const exportData = useMemo<Record<string, unknown>[]>(
|
||||
() => sortedLogs.map((l) => ({
|
||||
ID: l.id,
|
||||
Action: l.action,
|
||||
"Admin ID": l.adminId,
|
||||
Details: l.details || "",
|
||||
Date: l.createdAt,
|
||||
})),
|
||||
[sortedLogs]
|
||||
);
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setOpenRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -120,15 +162,21 @@ export function AuditPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Audit Log</h2>
|
||||
<ExportButton data={exportData} filename="audit-log" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<div className="max-h-[calc(100vh-14rem)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4">
|
||||
<SkeletonTable />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<FileText className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p className="text-lg font-medium">No audit entries</p>
|
||||
<p className="text-sm">Audit log is empty.</p>
|
||||
</div>
|
||||
@@ -137,14 +185,18 @@ export function AuditPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead className="w-40">Action</TableHead>
|
||||
<TableHead className="w-40">
|
||||
<SortableHeader column="action" label="Action" sortColumn={sortColumn} sortDirection={sortDirection} onSort={handleSort} />
|
||||
</TableHead>
|
||||
<TableHead className="w-28">Admin ID</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
<TableHead className="w-36">Date</TableHead>
|
||||
<TableHead className="w-36">
|
||||
<SortableHeader column="date" label="Date" sortColumn={sortColumn} sortDirection={sortDirection} onSort={handleSort} />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
{sortedLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-mono text-sm">{log.id}</TableCell>
|
||||
<TableCell>
|
||||
@@ -182,7 +234,7 @@ export function AuditPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(log.createdAt), "MMM d, HH:mm")}
|
||||
{format(new Date(log.createdAt), "MMM d, yyyy HH:mm")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -694,7 +694,7 @@ export function CatalogPage() {
|
||||
)}
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[calc(100vh-14rem)] p-2">
|
||||
<div className="flex-1 overflow-y-auto max-h-[calc(100vh-12rem)] p-2">
|
||||
{countries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No locations yet. Add one above.
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, FolderOpen } from "lucide-react";
|
||||
|
||||
interface LocationItem {
|
||||
id: number;
|
||||
@@ -206,7 +206,9 @@ export function CategoriesPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">Categories</h2>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleAdd} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Category
|
||||
@@ -214,11 +216,12 @@ export function CategoriesPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<div className="max-h-[calc(100vh-14rem)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4"><SkeletonTable /></div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<FolderOpen className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p className="text-lg font-medium">No categories</p>
|
||||
<p className="text-sm">Create your first category to get started.</p>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ActivityFeed } from "@/components/layout/activity-feed";
|
||||
|
||||
import {
|
||||
Users,
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
Clock,
|
||||
XCircle,
|
||||
Tag,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -87,14 +88,6 @@ interface TopCountry {
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
type: "purchase" | "audit";
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface WalletSummary {
|
||||
walletType: string;
|
||||
count: number;
|
||||
@@ -109,7 +102,6 @@ interface DashboardData {
|
||||
topSpenders: TopSpender[];
|
||||
revenueByCategory: RevenueByCategory[];
|
||||
topCountries: TopCountry[];
|
||||
activities: Activity[];
|
||||
walletSummary: WalletSummary[];
|
||||
}
|
||||
|
||||
@@ -123,16 +115,6 @@ function formatCrypto(val: number): string {
|
||||
return val.toFixed(8);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function shortDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
@@ -173,7 +155,7 @@ function KpiCard({
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
@@ -252,7 +234,7 @@ export function DashboardPage() {
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const { stats, chartData, topProducts, topSpenders, revenueByCategory, topCountries, activities, walletSummary } = data;
|
||||
const { stats, chartData, topProducts, topSpenders, revenueByCategory, topCountries, walletSummary } = data;
|
||||
|
||||
// Prepare chart datasets
|
||||
const revenue7Data = chartData.days.map((day, i) => ({
|
||||
@@ -294,20 +276,22 @@ export function DashboardPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* ── Page Title ── */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<h2 className="text-xl font-semibold">Dashboard</h2>
|
||||
<p className="text-sm text-muted-foreground">Overview of your Telegram Shop</p>
|
||||
</div>
|
||||
|
||||
{/* ── KPI Cards ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{kpis.map((kpi) => (
|
||||
<KpiCard key={kpi.title} {...kpi} icon={kpi.icon} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Charts Grid ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 1. Revenue 7 days */}
|
||||
@@ -529,56 +513,10 @@ export function DashboardPage() {
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom Section: Activity Feed + Wallet Summary ── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Activity Feed */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<CardTitle className="text-sm font-medium">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{activities.length > 0 ? (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto pr-1">
|
||||
{activities.map((activity) => (
|
||||
<div
|
||||
key={`${activity.type}-${activity.id}`}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div
|
||||
className={"flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-0.5 "}
|
||||
style={{
|
||||
backgroundColor:
|
||||
activity.type === "purchase"
|
||||
? `${CHART_1}15`
|
||||
: `${CHART_2}15`,
|
||||
}}
|
||||
>
|
||||
{activity.type === "purchase" ? (
|
||||
<ShoppingCart
|
||||
className="h-4 w-4"
|
||||
style={{ color: CHART_1 }}
|
||||
/>
|
||||
) : (
|
||||
<FileText
|
||||
className="h-4 w-4"
|
||||
style={{ color: CHART_2 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{activity.title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{activity.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{formatDate(activity.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-muted-foreground text-sm">No recent activity</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Separator />
|
||||
|
||||
{/* ── Bottom Section: Wallet Summary + Activity Feed ── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Wallet Summary */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
@@ -614,6 +552,9 @@ export function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Activity Feed (full width) ── */}
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
167
src/components/layout/activity-feed.tsx
Normal file
167
src/components/layout/activity-feed.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
LogIn,
|
||||
DollarSign,
|
||||
UserX,
|
||||
KeyRound,
|
||||
Package,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
UserPlus,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
|
||||
interface AuditItem {
|
||||
id: number;
|
||||
action: string;
|
||||
adminId: string;
|
||||
details: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ─── Icon mapping ─────────────────────────────────────────
|
||||
|
||||
const ICON_MAP: Record<
|
||||
string,
|
||||
{ icon: React.ComponentType<{ className?: string }>; color: string }
|
||||
> = {
|
||||
login: { icon: LogIn, color: "#3b82f6" },
|
||||
balance_adjust: { icon: DollarSign, color: "#f97316" },
|
||||
status_toggle: { icon: UserX, color: "#ef4444" },
|
||||
seed_phrase_viewed: { icon: KeyRound, color: "#a855f7" },
|
||||
product_created: { icon: Package, color: "#22c55e" },
|
||||
settings_changed: { icon: Settings, color: "#22c55e" },
|
||||
purchase_approved: { icon: CheckCircle, color: "#22c55e" },
|
||||
wallet_added: { icon: Wallet, color: "#06b6d4" },
|
||||
commission_paid: { icon: CreditCard, color: "#f59e0b" },
|
||||
user_registered: { icon: UserPlus, color: "#14b8a6" },
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = { icon: FileText, color: "#6b7280" };
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 60) return `${diffSec}s ago`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
function actionDescription(action: string, details: string | null): string {
|
||||
const label = action.replace(/_/g, " ");
|
||||
if (!details) return label;
|
||||
try {
|
||||
const obj = JSON.parse(details);
|
||||
if (obj.username) return `${label} — ${obj.username}`;
|
||||
if (obj.target) return `${label} — ${obj.target}`;
|
||||
if (obj.userId) return `${label} — user #${obj.userId}`;
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────
|
||||
|
||||
export function ActivityFeed() {
|
||||
const [items, setItems] = useState<AuditItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchFeed = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/audit/bulk?limit=15");
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setItems(json.data ?? []);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeed();
|
||||
const interval = setInterval(fetchFeed, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchFeed]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{loading ? (
|
||||
<div className="max-h-48 animate-pulse space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 rounded-md border p-2"
|
||||
>
|
||||
<div className="h-6 w-6 rounded-full bg-muted" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-3 w-3/4 rounded bg-muted" />
|
||||
<div className="h-2 w-1/3 rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : items.length > 0 ? (
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{items.map((item) => {
|
||||
const mapping = ICON_MAP[item.action] ?? DEFAULT_ICON;
|
||||
const Icon = mapping.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border/50 px-3 py-2"
|
||||
>
|
||||
<div
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${mapping.color}15` }}
|
||||
>
|
||||
<Icon
|
||||
className="h-3.5 w-3.5"
|
||||
style={{ color: mapping.color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm truncate leading-tight">
|
||||
{actionDescription(item.action, item.details)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{relativeTime(item.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-48 items-center justify-center text-muted-foreground text-sm">
|
||||
No recent activity
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Moon, Sun, LogOut, User } from "lucide-react";
|
||||
import { Moon, Sun, LogOut, User, Search } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette";
|
||||
import { AppBreadcrumbs } from "@/components/layout/breadcrumbs";
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
"/": "Dashboard",
|
||||
@@ -48,14 +50,27 @@ export function AdminHeader() {
|
||||
(hash.startsWith("/users/")
|
||||
? "User Detail"
|
||||
: hash.split("/").pop()?.charAt(0).toUpperCase() +
|
||||
hash.split("/").pop()?.slice(1) ||
|
||||
hash.split("/").pop()?.slice(1) ||
|
||||
"Page");
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<h1 className="text-base font-semibold flex-1 truncate">{title}</h1>
|
||||
<AppBreadcrumbs />
|
||||
<h1 className="text-base font-semibold flex-1 truncate hidden sm:block">
|
||||
{title}
|
||||
</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={openCommandPalette}
|
||||
className="shrink-0"
|
||||
title="Search (Ctrl+K)"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
<span className="sr-only">Search</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -104,6 +119,7 @@ export function AdminHeader() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CommandPalette />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderTree,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
@@ -41,7 +43,7 @@ const mainNav = [
|
||||
{ title: "Catalog", href: "/catalog", icon: FolderTree },
|
||||
{ title: "Users", href: "/users", icon: Users },
|
||||
{ title: "Wallets", href: "/wallets", icon: Wallet },
|
||||
{ title: "Purchases", href: "/purchases", icon: ShoppingCart },
|
||||
{ title: "Purchases", href: "/purchases", icon: ShoppingCart, badge: true },
|
||||
{ title: "Audit Log", href: "/audit", icon: FileText },
|
||||
];
|
||||
|
||||
@@ -55,9 +57,62 @@ const systemNav = [
|
||||
{ title: "Locales", href: "/locales", icon: Languages },
|
||||
];
|
||||
|
||||
function usePendingCount(isAuthenticated: boolean) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
|
||||
const load = () => {
|
||||
fetch("/api/purchases/bulk?status=pending&limit=1")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data) setCount(data.total ?? 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
load();
|
||||
window.addEventListener("focus", load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", load);
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function useConnectionStatus() {
|
||||
const { checkSession } = useAuthStore();
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const check = () => {
|
||||
checkSession().then((valid) => {
|
||||
if (!cancelled) setConnected(valid);
|
||||
});
|
||||
};
|
||||
|
||||
check();
|
||||
window.addEventListener("focus", check);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", check);
|
||||
};
|
||||
}, [checkSession]);
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { role, logout } = useAuthStore();
|
||||
const { role, logout, isAuthenticated } = useAuthStore();
|
||||
const pendingCount = usePendingCount(isAuthenticated);
|
||||
const connected = useConnectionStatus();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -94,6 +149,11 @@ export function AdminSidebar() {
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{item.badge && pendingCount > 0 && (
|
||||
<SidebarMenuBadge className="bg-destructive text-destructive-foreground">
|
||||
{pendingCount}
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
@@ -189,6 +249,16 @@ export function AdminSidebar() {
|
||||
<LogOut className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-2 pb-2 group-data-[collapsible=icon]:justify-center">
|
||||
<span
|
||||
className={`size-2 rounded-full shrink-0 ${
|
||||
connected ? "bg-green-500" : "bg-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground group-data-[collapsible=icon]:hidden">
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
124
src/components/layout/breadcrumbs.tsx
Normal file
124
src/components/layout/breadcrumbs.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
|
||||
interface Crumb {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const pageLabels: Record<string, string> = {
|
||||
"": "Dashboard",
|
||||
catalog: "Catalog",
|
||||
users: "Users",
|
||||
wallets: "Wallets",
|
||||
purchases: "Purchases",
|
||||
audit: "Audit Log",
|
||||
categories: "Categories",
|
||||
locations: "Locations",
|
||||
settings: "Settings",
|
||||
locales: "Locales",
|
||||
seed: "Danger Zone",
|
||||
login: "Sign In",
|
||||
};
|
||||
|
||||
function parseHash(hash: string): Crumb[] {
|
||||
const path = hash.replace(/^#\/?/, "");
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
const crumbs: Crumb[] = [{ label: "Home", href: "/" }];
|
||||
|
||||
if (segments.length === 0) {
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
// Build parent href incrementally
|
||||
let href = "";
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
href += "/" + segments[i];
|
||||
const isLast = i === segments.length - 1;
|
||||
const label = pageLabels[segments[i]] || segments[i];
|
||||
crumbs.push({ label, href });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
export function AppBreadcrumbs() {
|
||||
const [crumbs, setCrumbs] = useState<Crumb[]>([{ label: "Home", href: "/" }]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setCrumbs(parseHash(window.location.hash));
|
||||
update();
|
||||
window.addEventListener("hashchange", update);
|
||||
return () => window.removeEventListener("hashchange", update);
|
||||
}, []);
|
||||
|
||||
if (crumbs.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{/* Desktop: show all breadcrumbs */}
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1;
|
||||
return (
|
||||
<BreadcrumbItem key={crumb.href} className="hidden sm:inline-flex">
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.hash = crumb.href;
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
{!isLast && <BreadcrumbSeparator />}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Mobile: show only last 2 breadcrumbs with ellipsis */}
|
||||
{crumbs.length > 2 && (
|
||||
<BreadcrumbItem className="sm:hidden">
|
||||
<BreadcrumbEllipsis />
|
||||
<BreadcrumbSeparator />
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{crumbs.length >= 2 && (
|
||||
<BreadcrumbItem className="sm:hidden">
|
||||
<BreadcrumbLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const href = crumbs[crumbs.length - 2].href;
|
||||
window.location.hash = href;
|
||||
}}
|
||||
>
|
||||
{crumbs[crumbs.length - 2].label}
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
<BreadcrumbItem className="sm:hidden">
|
||||
<BreadcrumbPage>{crumbs[crumbs.length - 1].label}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
129
src/components/layout/command-palette.tsx
Normal file
129
src/components/layout/command-palette.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderTree,
|
||||
Users,
|
||||
Wallet,
|
||||
ShoppingCart,
|
||||
FileText,
|
||||
Tag,
|
||||
MapPin,
|
||||
Settings,
|
||||
Languages,
|
||||
AlertTriangle,
|
||||
Database,
|
||||
Trash2,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
const COMMAND_TOGGLE = "command-palette:toggle";
|
||||
|
||||
export function openCommandPalette() {
|
||||
window.dispatchEvent(new CustomEvent(COMMAND_TOGGLE));
|
||||
}
|
||||
|
||||
const navigationItems = [
|
||||
{ label: "Dashboard", href: "/", Icon: LayoutDashboard },
|
||||
{ label: "Catalog", href: "/catalog", Icon: FolderTree },
|
||||
{ label: "Users", href: "/users", Icon: Users },
|
||||
{ label: "Wallets", href: "/wallets", Icon: Wallet },
|
||||
{ label: "Purchases", href: "/purchases", Icon: ShoppingCart },
|
||||
{ label: "Audit Log", href: "/audit", Icon: FileText },
|
||||
{ label: "Categories", href: "/categories", Icon: Tag },
|
||||
{ label: "Locations", href: "/locations", Icon: MapPin },
|
||||
{ label: "Settings", href: "/settings", Icon: Settings },
|
||||
{ label: "Locales", href: "/locales", Icon: Languages },
|
||||
{ label: "Danger Zone", href: "/seed", Icon: AlertTriangle },
|
||||
] as const;
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { logout } = useAuthStore();
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggle]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggle = () => setOpen(true);
|
||||
window.addEventListener(COMMAND_TOGGLE, handleToggle);
|
||||
return () => window.removeEventListener(COMMAND_TOGGLE, handleToggle);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading="Navigation">
|
||||
{navigationItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.href}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
window.location.hash = item.href;
|
||||
}}
|
||||
>
|
||||
<item.Icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Actions">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
window.location.hash = "/seed";
|
||||
}}
|
||||
>
|
||||
<Database className="size-4" />
|
||||
<span>Seed Demo Data</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
window.location.hash = "/seed";
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span>Clear Data</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
window.location.hash = "/login";
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-4 text-destructive" />
|
||||
<span className="text-destructive">Logout</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { Languages } from "lucide-react";
|
||||
|
||||
const LANGS = ["en", "es", "de"];
|
||||
const LANG_LABELS: Record<string, string> = { en: "English", es: "Spanish", de: "German" };
|
||||
@@ -116,13 +117,16 @@ export function LocalesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">Locales</h2>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<div className="max-h-[calc(100vh-14rem)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4"><SkeletonTable /></div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Languages className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p className="text-lg font-medium">No locale data</p>
|
||||
<p className="text-sm">No translations found.</p>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, MapPin } from "lucide-react";
|
||||
|
||||
interface LocationRow {
|
||||
id: number;
|
||||
@@ -183,7 +183,9 @@ export function LocationsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold">Locations</h2>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleAdd} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Location
|
||||
@@ -191,11 +193,12 @@ export function LocationsPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<div className="max-h-[calc(100vh-14rem)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4"><SkeletonTable /></div>
|
||||
) : locations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<MapPin className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p className="text-lg font-medium">No locations</p>
|
||||
<p className="text-sm">Create your first location to get started.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Copy, ShoppingCart } from "lucide-react";
|
||||
import { ExportButton } from "@/components/shared/export-button";
|
||||
import { SortableHeader } from "@/components/shared/sortable-header";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
|
||||
interface PurchaseRow {
|
||||
id: number;
|
||||
@@ -82,6 +85,8 @@ export function PurchasesPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortColumn, setSortColumn] = useState<string>("");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(null);
|
||||
const limit = 50;
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -105,6 +110,49 @@ export function PurchasesPage() {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortColumn === column) {
|
||||
if (sortDirection === "asc") setSortDirection("desc");
|
||||
else if (sortDirection === "desc") {
|
||||
setSortColumn("");
|
||||
setSortDirection(null);
|
||||
}
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedPurchases = useMemo(() => {
|
||||
if (!sortColumn || !sortDirection) return purchases;
|
||||
return [...purchases].sort((a, b) => {
|
||||
let valA: unknown;
|
||||
let valB: unknown;
|
||||
if (sortColumn === "date") { valA = a.purchaseDate; valB = b.purchaseDate; }
|
||||
else if (sortColumn === "amount") { valA = a.totalPrice; valB = b.totalPrice; }
|
||||
else if (sortColumn === "status") { valA = a.status; valB = b.status; }
|
||||
else return 0;
|
||||
if (valA === valB) return 0;
|
||||
const cmp = valA < valB ? -1 : 1;
|
||||
return sortDirection === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [purchases, sortColumn, sortDirection]);
|
||||
|
||||
const exportData = useMemo<Record<string, unknown>[]>(
|
||||
() => sortedPurchases.map((p) => ({
|
||||
ID: p.id,
|
||||
User: p.user.username || `@${p.user.telegramId}`,
|
||||
Product: p.product.name,
|
||||
Qty: p.quantity,
|
||||
"Total Price": p.totalPrice,
|
||||
Currency: p.walletType || "",
|
||||
"TX Hash": p.txHash || "",
|
||||
Date: p.purchaseDate,
|
||||
Status: p.status,
|
||||
})),
|
||||
[sortedPurchases]
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const handleStatusChange = (s: string) => {
|
||||
@@ -112,13 +160,18 @@ export function PurchasesPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const copyHash = (hash: string) => {
|
||||
navigator.clipboard.writeText(hash);
|
||||
toast.success("Copied!");
|
||||
const copyHash = async (hash: string) => {
|
||||
const ok = await copyToClipboard(hash);
|
||||
if (ok) toast.success("Copied!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Purchases</h2>
|
||||
<ExportButton data={exportData} filename="purchases" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUS_TABS.map((s) => (
|
||||
<Button
|
||||
@@ -133,13 +186,14 @@ export function PurchasesPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<div className="max-h-[calc(100vh-14rem)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4">
|
||||
<SkeletonTable />
|
||||
</div>
|
||||
) : purchases.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<ShoppingCart className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p className="text-lg font-medium">No purchases found</p>
|
||||
<p className="text-sm">There are no purchases matching the current filter.</p>
|
||||
</div>
|
||||
@@ -151,15 +205,21 @@ export function PurchasesPage() {
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="w-16">Qty</TableHead>
|
||||
<TableHead className="w-28">Total Price</TableHead>
|
||||
<TableHead className="w-28">
|
||||
<SortableHeader column="amount" label="Total Price" sortColumn={sortColumn} sortDirection={sortDirection} onSort={handleSort} />
|
||||
</TableHead>
|
||||
<TableHead className="w-20">Currency</TableHead>
|
||||
<TableHead className="w-36">TX Hash</TableHead>
|
||||
<TableHead className="w-32">Date</TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
<TableHead className="w-32">
|
||||
<SortableHeader column="date" label="Date" sortColumn={sortColumn} sortDirection={sortDirection} onSort={handleSort} />
|
||||
</TableHead>
|
||||
<TableHead className="w-28">
|
||||
<SortableHeader column="status" label="Status" sortColumn={sortColumn} sortDirection={sortDirection} onSort={handleSort} />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchases.map((p) => (
|
||||
{sortedPurchases.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-mono text-sm">{p.id}</TableCell>
|
||||
<TableCell>
|
||||
@@ -197,7 +257,7 @@ export function PurchasesPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(p.purchaseDate), "MMM d, HH:mm")}
|
||||
{format(new Date(p.purchaseDate), "MMM d, yyyy HH:mm")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={p.status} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Database, Trash2, AlertTriangle, Loader2, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export function SeedPage() {
|
||||
@@ -88,7 +89,10 @@ export function SeedPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<h2 className="text-xl font-semibold">Database Seed</h2>
|
||||
|
||||
<Separator />
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5 text-muted-foreground" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { toast } from "sonner";
|
||||
import { Save, AlertTriangle } from "lucide-react";
|
||||
@@ -84,7 +85,9 @@ export function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<h2 className="text-xl font-semibold">Settings</h2>
|
||||
|
||||
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-950/20">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600" />
|
||||
<AlertDescription className="text-yellow-700 dark:text-yellow-400">
|
||||
@@ -92,6 +95,8 @@ export function SettingsPage() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Separator />
|
||||
|
||||
{!settings ? (
|
||||
<div className="space-y-6">
|
||||
{SECTIONS.map((s) => (
|
||||
@@ -102,8 +107,9 @@ export function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
SECTIONS.map((section) => (
|
||||
SECTIONS.map((section, idx) => (
|
||||
<div key={section.title}>
|
||||
{idx > 0 && <Separator className="mb-4" />}
|
||||
<h3 className="text-lg font-semibold mb-4">{section.title}</h3>
|
||||
<div className="space-y-4">
|
||||
{section.keys.map((key) => {
|
||||
|
||||
72
src/components/shared/export-button.tsx
Normal file
72
src/components/shared/export-button.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { Download, FileDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ExportButtonProps {
|
||||
data: Record<string, unknown>[];
|
||||
filename: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function toCsv(data: Record<string, unknown>[]): string {
|
||||
if (data.length === 0) return "";
|
||||
const headers = Object.keys(data[0]);
|
||||
const escape = (val: unknown): string => {
|
||||
const s = String(val ?? "");
|
||||
if (s.includes(",") || s.includes("\"") || s.includes("\n")) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const rows = data.map((row) => headers.map((h) => escape(row[h])).join(","));
|
||||
return [headers.join(","), ...rows].join("\n");
|
||||
}
|
||||
|
||||
function downloadFile(content: string, filename: string, mimeType: string) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function ExportButton({ data, filename, label }: ExportButtonProps) {
|
||||
const handleExportCsv = () => {
|
||||
if (data.length === 0) {
|
||||
toast.info("No data to export");
|
||||
return;
|
||||
}
|
||||
const csv = toCsv(data);
|
||||
downloadFile(csv, `${filename}.csv`, "text/csv;charset=utf-8;");
|
||||
toast.success(`Exported ${data.length} rows`);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
{label ?? "Export"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExportCsv}>
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
CSV
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
40
src/components/shared/sortable-header.tsx
Normal file
40
src/components/shared/sortable-header.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
|
||||
interface SortableHeaderProps {
|
||||
column: string;
|
||||
label: string;
|
||||
sortColumn: string;
|
||||
sortDirection: "asc" | "desc" | null;
|
||||
onSort: (column: string) => void;
|
||||
}
|
||||
|
||||
export function SortableHeader({
|
||||
column,
|
||||
label,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: SortableHeaderProps) {
|
||||
const isActive = sortColumn === column;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className={`flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer ${
|
||||
isActive ? "text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{isActive && sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : isActive && sortDirection === "desc" ? (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
@@ -105,7 +107,7 @@ function PurchaseStatusBadge({ status }: { status: string }) {
|
||||
|
||||
function ProfileSkeleton() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
@@ -204,7 +206,7 @@ export function UserDetailPage({ userId }: { userId: string }) {
|
||||
if (loading) return <ProfileSkeleton />;
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-4 md:p-6">
|
||||
<Button variant="ghost" onClick={() => { window.location.hash = "/users"; }}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Users
|
||||
</Button>
|
||||
@@ -214,12 +216,10 @@ export function UserDetailPage({ userId }: { userId: string }) {
|
||||
}
|
||||
|
||||
const isBlocked = user.status === 2;
|
||||
const registeredDate = new Date(user.createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
const registeredDate = format(new Date(user.createdAt), "MMM d, yyyy HH:mm");
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
@@ -231,9 +231,9 @@ export function UserDetailPage({ userId }: { userId: string }) {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{user.username || `User #${user.id}`}
|
||||
</h1>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">ID: {user.id} · Telegram: {user.telegramId}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,6 +360,8 @@ export function UserDetailPage({ userId }: { userId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Purchases Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -400,9 +402,7 @@ export function UserDetailPage({ userId }: { userId: string }) {
|
||||
<PurchaseStatusBadge status={p.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(p.purchaseDate).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
})}
|
||||
{format(new Date(p.purchaseDate), "MMM d, yyyy HH:mm")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Search, Eye } from "lucide-react";
|
||||
import { Search, Eye, Users } from "lucide-react";
|
||||
import { ExportButton } from "@/components/shared/export-button";
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
@@ -54,6 +55,12 @@ function StatusBadge({ status }: { status: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: number): string {
|
||||
if (status === 0) return "Active";
|
||||
if (status === 2) return "Blocked";
|
||||
return "Deleted";
|
||||
}
|
||||
|
||||
function SkeletonTable() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -123,23 +130,42 @@ export function UsersPage() {
|
||||
fetchUsers(debouncedSearch, p);
|
||||
};
|
||||
|
||||
const exportData = useMemo<Record<string, unknown>[]>(
|
||||
() => users.map((u) => ({
|
||||
ID: u.id,
|
||||
"Telegram ID": u.telegramId,
|
||||
Username: u.username || "",
|
||||
Country: u.country || "",
|
||||
City: u.city || "",
|
||||
Status: statusLabel(u.status),
|
||||
"Total Balance": u.totalBalance,
|
||||
"Bonus Balance": u.bonusBalance,
|
||||
Wallets: u._count.wallets,
|
||||
Purchases: u._count.purchases,
|
||||
})),
|
||||
[users]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
||||
<h2 className="text-xl font-semibold">Users</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total} user{total !== 1 ? "s" : ""} total
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search username or Telegram ID..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search username or Telegram ID..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<ExportButton data={exportData} filename="users" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +177,10 @@ export function UsersPage() {
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-destructive">{error}</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No users found</div>
|
||||
<div className="p-8 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Users className="h-10 w-10 mb-2 opacity-40" />
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
@@ -296,11 +296,11 @@ export function WalletsPage() {
|
||||
const currencyTypes = ['BTC', 'LTC', 'ETH', 'USDT', 'USDC'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Wallet className="h-8 w-8 text-orange-500" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Wallets</h1>
|
||||
<h2 className="text-xl font-semibold">Wallets</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage crypto wallets, view balances, and track commissions</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -341,7 +341,7 @@ export function WalletsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-[calc(100vh-20rem)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto">
|
||||
{userListLoading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
|
||||
25
src/lib/clipboard.ts
Normal file
25
src/lib/clipboard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const globalForPrisma = globalThis as unknown as {
|
||||
export const db =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: ['query'],
|
||||
log: ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
|
||||
Reference in New Issue
Block a user