diff --git a/agent-ctx/new-features-2-main-coordinator.md b/agent-ctx/new-features-2-main-coordinator.md
new file mode 100644
index 0000000..17ae314
--- /dev/null
+++ b/agent-ctx/new-features-2-main-coordinator.md
@@ -0,0 +1,18 @@
+# Task ID: new-features-2
+## Agent: Main Coordinator
+## Task: Activity Feed, Export Button, Sortable Header, Clipboard Utility, Integration
+
+### Files Created
+- `src/components/layout/activity-feed.tsx` — ActivityFeed component (fetches audit/bulk, 30s refresh, color-coded icons, relative time)
+- `src/components/shared/export-button.tsx` — ExportButton component (CSV export via DropdownMenu, Blob download)
+- `src/components/shared/sortable-header.tsx` — SortableHeader component (3-state sort toggle with arrow icons)
+- `src/lib/clipboard.ts` — copyToClipboard utility (navigator.clipboard + textarea fallback)
+
+### Files Modified
+- `src/components/dashboard/dashboard-page.tsx` — Replaced inline activity feed with ActivityFeed, removed unused imports/types
+- `src/components/purchases/purchases-page.tsx` — Added ExportButton, SortableHeader (date/amount/status), client-side sort, clipboard util
+- `src/components/audit/audit-page.tsx` — Added ExportButton, SortableHeader (action/date), client-side sort
+- `src/components/users/users-page.tsx` — Added ExportButton next to search input
+
+### Lint Result
+- ESLint: 0 errors, 0 warnings
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 328dbd1..2b4749d 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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: () => ,
-});
-const CatalogPage = dynamic(() => import("@/components/catalog/catalog-page"), {
- ssr: false,
- loading: () => ,
-});
-const UsersPage = dynamic(() => import("@/components/users/users-page"), {
- ssr: false,
- loading: () => ,
-});
-const UserDetailPage = dynamic(() => import("@/components/users/user-detail-page"), {
- ssr: false,
- loading: () => ,
-});
-const WalletsPage = dynamic(() => import("@/components/wallets/wallets-page"), {
- ssr: false,
- loading: () => ,
-});
-const PurchasesPage = dynamic(() => import("@/components/purchases/purchases-page"), {
- ssr: false,
- loading: () => ,
-});
-const AuditPage = dynamic(() => import("@/components/audit/audit-page"), {
- ssr: false,
- loading: () => ,
-});
-const CategoriesPage = dynamic(() => import("@/components/categories/categories-page"), {
- ssr: false,
- loading: () => ,
-});
-const LocationsPage = dynamic(() => import("@/components/locations/locations-page"), {
- ssr: false,
- loading: () => ,
-});
-const SettingsPage = dynamic(() => import("@/components/settings/settings-page"), {
- ssr: false,
- loading: () => ,
-});
-const LocalesPage = dynamic(() => import("@/components/locales/locales-page"), {
- ssr: false,
- loading: () => ,
-});
-const SeedPage = dynamic(() => import("@/components/seed/seed-page"), {
- ssr: false,
- loading: () => ,
-});
-
-function PageSkeleton() {
- return (
-
-
-
- {[...Array(3)].map((_, i) => (
-
- ))}
-
-
-
- );
-}
+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 ;
if (page === "/seed") return ;
return (
-
+
);
diff --git a/src/components/audit/audit-page.tsx b/src/components/audit/audit-page.tsx
index 87d25b2..17df588 100644
--- a/src/components/audit/audit-page.tsx
+++ b/src/components/audit/audit-page.tsx
@@ -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
>(new Set());
+ const [sortColumn, setSortColumn] = useState("");
+ 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[]>(
+ () => 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 (
-
+
+
+
Audit Log
+
+
+
-
+
{loading ? (
) : logs.length === 0 ? (
+
No audit entries
Audit log is empty.
@@ -137,14 +185,18 @@ export function AuditPage() {
ID
- Action
+
+
+
Admin ID
Details
- Date
+
+
+
- {logs.map((log) => (
+ {sortedLogs.map((log) => (
{log.id}
@@ -182,7 +234,7 @@ export function AuditPage() {
)}
- {format(new Date(log.createdAt), "MMM d, HH:mm")}
+ {format(new Date(log.createdAt), "MMM d, yyyy HH:mm")}
))}
diff --git a/src/components/catalog/catalog-page.tsx b/src/components/catalog/catalog-page.tsx
index 09a2b37..9c9c3b2 100644
--- a/src/components/catalog/catalog-page.tsx
+++ b/src/components/catalog/catalog-page.tsx
@@ -694,7 +694,7 @@ export function CatalogPage() {
)}
{/* Tree */}
-
+
{countries.length === 0 ? (
No locations yet. Add one above.
diff --git a/src/components/categories/categories-page.tsx b/src/components/categories/categories-page.tsx
index beedfe2..a3faf5e 100644
--- a/src/components/categories/categories-page.tsx
+++ b/src/components/categories/categories-page.tsx
@@ -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 (
-
+
+
Categories
+
-
+
{loading ? (
) : categories.length === 0 ? (
+
No categories
Create your first category to get started.
diff --git a/src/components/dashboard/dashboard-page.tsx b/src/components/dashboard/dashboard-page.tsx
index 6172137..671a49e 100644
--- a/src/components/dashboard/dashboard-page.tsx
+++ b/src/components/dashboard/dashboard-page.tsx
@@ -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 (
-
+
{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 (
-
+
{/* ── Page Title ── */}
-
Dashboard
+
Dashboard
Overview of your Telegram Shop
{/* ── KPI Cards ── */}
-
+
{kpis.map((kpi) => (
))}
+
+
{/* ── Charts Grid ── */}
{/* 1. Revenue 7 days */}
@@ -529,56 +513,10 @@ export function DashboardPage() {
- {/* ── Bottom Section: Activity Feed + Wallet Summary ── */}
-
- {/* Activity Feed */}
-
-
- Recent Activity
-
-
- {activities.length > 0 ? (
-
- {activities.map((activity) => (
-
-
- {activity.type === "purchase" ? (
-
- ) : (
-
- )}
-
-
-
{activity.title}
-
{activity.description}
-
{formatDate(activity.date)}
-
-
- ))}
-
- ) : (
- No recent activity
- )}
-
-
+
+ {/* ── Bottom Section: Wallet Summary + Activity Feed ── */}
+
{/* Wallet Summary */}
@@ -614,6 +552,9 @@ export function DashboardPage() {
+
+ {/* ── Activity Feed (full width) ── */}
+
);
}
diff --git a/src/components/layout/activity-feed.tsx b/src/components/layout/activity-feed.tsx
new file mode 100644
index 0000000..26722b8
--- /dev/null
+++ b/src/components/layout/activity-feed.tsx
@@ -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
([]);
+ 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 (
+
+
+
+ Recent Activity
+
+
+
+ {loading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : items.length > 0 ? (
+
+ {items.map((item) => {
+ const mapping = ICON_MAP[item.action] ?? DEFAULT_ICON;
+ const Icon = mapping.icon;
+ return (
+
+
+
+
+
+
+ {actionDescription(item.action, item.details)}
+
+
+ {relativeTime(item.createdAt)}
+
+
+
+ );
+ })}
+
+ ) : (
+
+ No recent activity
+
+ )}
+
+
+ );
+}
diff --git a/src/components/layout/admin-header.tsx b/src/components/layout/admin-header.tsx
index 3a33a33..9769d7e 100644
--- a/src/components/layout/admin-header.tsx
+++ b/src/components/layout/admin-header.tsx
@@ -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 = {
"/": "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 (
- {title}
+
+
+ {title}
+
+
);
}
diff --git a/src/components/layout/admin-sidebar.tsx b/src/components/layout/admin-sidebar.tsx
index 00a3a16..df21aa4 100644
--- a/src/components/layout/admin-sidebar.tsx
+++ b/src/components/layout/admin-sidebar.tsx
@@ -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 (
@@ -94,6 +149,11 @@ export function AdminSidebar() {
{item.title}
+ {item.badge && pendingCount > 0 && (
+
+ {pendingCount}
+
+ )}
))}
@@ -189,6 +249,16 @@ export function AdminSidebar() {
+
+
+
+ {connected ? "Connected" : "Disconnected"}
+
+
diff --git a/src/components/layout/breadcrumbs.tsx b/src/components/layout/breadcrumbs.tsx
new file mode 100644
index 0000000..a698544
--- /dev/null
+++ b/src/components/layout/breadcrumbs.tsx
@@ -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
= {
+ "": "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([{ 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 (
+
+
+ {/* Desktop: show all breadcrumbs */}
+ {crumbs.map((crumb, index) => {
+ const isLast = index === crumbs.length - 1;
+ return (
+
+ {isLast ? (
+ {crumb.label}
+ ) : (
+ {
+ e.preventDefault();
+ window.location.hash = crumb.href;
+ }}
+ >
+ {crumb.label}
+
+ )}
+ {!isLast && }
+
+ );
+ })}
+
+ {/* Mobile: show only last 2 breadcrumbs with ellipsis */}
+ {crumbs.length > 2 && (
+
+
+
+
+ )}
+ {crumbs.length >= 2 && (
+
+ {
+ e.preventDefault();
+ const href = crumbs[crumbs.length - 2].href;
+ window.location.hash = href;
+ }}
+ >
+ {crumbs[crumbs.length - 2].label}
+
+
+
+ )}
+
+ {crumbs[crumbs.length - 1].label}
+
+
+
+ );
+}
diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx
new file mode 100644
index 0000000..c061ad3
--- /dev/null
+++ b/src/components/layout/command-palette.tsx
@@ -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 (
+
+
+
+ No results found.
+
+ {navigationItems.map((item) => (
+ {
+ setOpen(false);
+ window.location.hash = item.href;
+ }}
+ >
+
+ {item.label}
+
+ ))}
+
+
+
+ {
+ setOpen(false);
+ window.location.hash = "/seed";
+ }}
+ >
+
+ Seed Demo Data
+
+ {
+ setOpen(false);
+ window.location.hash = "/seed";
+ }}
+ >
+
+ Clear Data
+
+ {
+ setOpen(false);
+ logout();
+ window.location.hash = "/login";
+ }}
+ >
+
+ Logout
+
+
+
+
+ );
+}
diff --git a/src/components/locales/locales-page.tsx b/src/components/locales/locales-page.tsx
index 43ff251..76f4e5f 100644
--- a/src/components/locales/locales-page.tsx
+++ b/src/components/locales/locales-page.tsx
@@ -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 = { en: "English", es: "Spanish", de: "German" };
@@ -116,13 +117,16 @@ export function LocalesPage() {
};
return (
-
+
+
Locales
+
-
+
{loading ? (
) : rows.length === 0 ? (
+
No locale data
No translations found.
diff --git a/src/components/locations/locations-page.tsx b/src/components/locations/locations-page.tsx
index af49e18..249c03b 100644
--- a/src/components/locations/locations-page.tsx
+++ b/src/components/locations/locations-page.tsx
@@ -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 (
-
+
+
Locations
+
-
+
{loading ? (
) : locations.length === 0 ? (
+
No locations
Create your first location to get started.
diff --git a/src/components/purchases/purchases-page.tsx b/src/components/purchases/purchases-page.tsx
index 00cf381..5bae984 100644
--- a/src/components/purchases/purchases-page.tsx
+++ b/src/components/purchases/purchases-page.tsx
@@ -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
("");
const [loading, setLoading] = useState(true);
+ const [sortColumn, setSortColumn] = useState("");
+ 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[]>(
+ () => 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 (
-
+
+
+
Purchases
+
+
+
{STATUS_TABS.map((s) => (