Files
telegram-shop/admin-next/src/components/layout/admin-header.tsx
NW e90dcb60c9
All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
feat: shop activation + DB commission wallets + onion display (deploy pipeline)
- src/services/commissionService.js: read commission wallets and shop_activated from site_settings (DB) with env fallback, 30s cache
- src/handlers/adminHandlers/adminWalletsHandler.js: commission wallets from CommissionService instead of static config
- src/handlers/userHandlers/userHandler.js: canUseBot blocks all users when shop not activated
- src/migrations/015_shop_activation.js: seed shop_activated + commission_wallet_* into site_settings; register in runner
- admin-next: /api/system/info (onion hosts), /api/commission-wallets GET/PUT (super admin), /api/activation GET/PUT (super admin); lib/commission-wallets.ts shared helper; onion badge in header; shop activation card in settings; wallet editor in wallets page
- test: commissionService.test.js (10 cases: DB/env fallback, caching)
2026-08-11 18:59:18 +01:00

240 lines
7.9 KiB
TypeScript
Executable File

"use client"
import { useState, useEffect } from "react";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Moon, Sun, LogOut, User, Search, Globe, Copy } from "lucide-react";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth-store";
import { CommandPalette, openCommandPalette } from "@/components/layout/command-palette";
import { AppBreadcrumbs } from "@/components/layout/breadcrumbs";
import { QuickActions } from "@/components/layout/quick-actions";
import { NotificationsPanel } from "@/components/layout/notifications-panel";
const pageTitles: 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 getInitialTime() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
function RealtimeClock() {
const [time, setTime] = useState(getInitialTime);
useEffect(() => {
const interval = setInterval(() => {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
setTime(`${hh}:${mm}`);
}, 1000);
return () => clearInterval(interval);
}, []);
const parts = time.split(":");
return (
<span className="text-xs text-muted-foreground font-mono tabular-nums hidden md:flex items-center">
{parts[0]}<span className="colon-pulse mx-px">:</span>{parts[1]}
</span>
);
}
export function AdminHeader() {
const [hash, setHash] = useState("");
const { theme, setTheme } = useTheme();
const { role, logout } = useAuthStore();
const [logoutOpen, setLogoutOpen] = useState(false);
const [onion, setOnion] = useState("");
useEffect(() => {
const update = () => setHash(window.location.hash.slice(1) || "/");
update();
window.addEventListener("hashchange", update);
return () => window.removeEventListener("hashchange", update);
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/system/info");
if (!res.ok) throw new Error("Failed to fetch system info");
const data = await res.json();
if (cancelled) return;
setOnion(data.adminOnion || "");
} catch {
if (cancelled) return;
setOnion("");
}
})();
return () => {
cancelled = true;
};
}, []);
const copyOnion = async () => {
if (!onion) return;
try {
await navigator.clipboard.writeText(onion);
toast.success("Onion address copied");
} catch {
toast.error("Failed to copy onion address");
}
};
const title =
pageTitles[hash] ||
(hash.startsWith("/users/")
? "User Detail"
: hash.split("/").pop()?.charAt(0).toUpperCase() +
hash.split("/").pop()?.slice(1) ||
"Page");
return (
<header className="flex h-14 shrink-0 items-center gap-2 border-b-0 px-4 gradient-border-b bg-background/80 backdrop-blur-md relative z-10" style={{ borderBottomStyle: 'solid', borderBottomWidth: '1px' }}>
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<AppBreadcrumbs />
<h1 className="text-base font-semibold flex-1 truncate hidden sm:block">
{title}
</h1>
{/* 1. Clock (hidden on mobile) */}
<RealtimeClock />
{/* 1b. Onion host badge (hidden on mobile) */}
<button
type="button"
onClick={copyOnion}
disabled={!onion}
title={onion ? "Copy onion address" : "Onion address not ready yet"}
className="hidden md:flex items-center gap-1.5 rounded-md border border-border bg-muted/50 px-2 py-1 text-xs font-mono text-muted-foreground hover:bg-muted transition-colors shrink-0 max-w-[220px]"
>
<Globe className="size-3.5 shrink-0" />
<span className="truncate">{onion || "onion: —"}</span>
{onion && <Copy className="size-3 shrink-0 opacity-60" />}
</button>
{/* 2. Command palette search button */}
<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>
{/* 3. Notifications bell button */}
<NotificationsPanel />
{/* 4. Quick actions zap button */}
<QuickActions />
{/* 5. Theme toggle */}
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="shrink-0"
>
<Sun className="size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
{/* 6. User dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="size-8">
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{role === "super_admin" ? "SA" : "AD"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-2 py-1.5">
<p className="text-sm font-medium">
{role === "super_admin" ? "Super Admin" : "Admin"}
</p>
<Badge
variant={role === "super_admin" ? "default" : "secondary"}
className="text-[10px] px-1.5 py-0 mt-1"
>
{role}
</Badge>
</div>
<DropdownMenuItem onClick={() => { window.location.hash = "/settings"; }}>
<User className="mr-2 size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLogoutOpen(true)}
className="text-destructive"
>
<LogOut className="mr-2 size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={logoutOpen} onOpenChange={setLogoutOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to sign out? You will need to
re-enter your admin token.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
logout();
window.location.hash = "/login";
}}
className="bg-destructive text-white hover:bg-destructive/90"
>
Sign out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CommandPalette />
</header>
);
}