All checks were successful
Release: multi-arch Docker images / build-push (push) Successful in 11m28s
- 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)
619 lines
25 KiB
TypeScript
Executable File
619 lines
25 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { toast } from "sonner";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { Bot, Shield, Wrench, Save, AlertTriangle, Download, Upload, Database, Info, Eye, EyeOff, ShieldCheck, Power, Copy, Globe, Link2 } from "lucide-react";
|
|
|
|
const MASKED_PLACEHOLDER = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
|
|
|
|
const KEY_META: Record<string, { label: string; description: string }> = {
|
|
BOT_TOKEN: { label: "Bot Token", description: "Telegram Bot API token from @BotFather" },
|
|
SUPPORT_LINK: { label: "Support Link", description: "URL shown to users for support" },
|
|
ADMIN_IDS: { label: "Admin Telegram IDs", description: "Comma-separated list of admin user IDs" },
|
|
SUPER_ADMIN_IDS: { label: "Super Admin IDs", description: "Comma-separated list of super admin IDs" },
|
|
WG_ENABLED: { label: "WireGuard VPN", description: "Enable WireGuard VPN for product delivery" },
|
|
WG_ENDPOINT: { label: "VPN Endpoint", description: "WireGuard server endpoint address" },
|
|
WG_ADDRESS: { label: "VPN Address", description: "WireGuard client address" },
|
|
WG_PUBLIC_KEY: { label: "VPN Public Key", description: "WireGuard server public key" },
|
|
WG_DNS: { label: "VPN DNS", description: "DNS server for VPN connection" },
|
|
ADMIN_PORT: { label: "Admin Port", description: "Port for the admin panel HTTP server" },
|
|
ADMIN_URL: { label: "Admin URL", description: "Public URL for the admin panel" },
|
|
CATALOG_PATH: { label: "Catalog Path", description: "File system path to the product catalog" },
|
|
GITEA_API_URL: { label: "Gitea API URL", description: "Gitea instance API endpoint URL" },
|
|
};
|
|
|
|
const SECTIONS = [
|
|
{
|
|
title: "Bot Configuration",
|
|
keys: ["BOT_TOKEN", "SUPPORT_LINK", "ADMIN_IDS", "SUPER_ADMIN_IDS"],
|
|
icon: Bot,
|
|
description: "Telegram bot and user management settings",
|
|
},
|
|
{
|
|
title: "WireGuard VPN",
|
|
keys: ["WG_ENABLED", "WG_ENDPOINT", "WG_ADDRESS", "WG_PUBLIC_KEY", "WG_DNS"],
|
|
icon: Shield,
|
|
description: "VPN configuration for secure product delivery",
|
|
},
|
|
{
|
|
title: "Admin Panel",
|
|
keys: ["ADMIN_PORT", "ADMIN_URL", "CATALOG_PATH", "GITEA_API_URL"],
|
|
icon: Wrench,
|
|
description: "Admin panel server and integration settings",
|
|
},
|
|
];
|
|
|
|
function SkeletonForm() {
|
|
return (
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-9 w-full" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SettingsPage() {
|
|
const { role } = useAuthStore();
|
|
const isSuperAdmin = role === "super_admin";
|
|
const [settings, setSettings] = useState<Record<string, string | boolean> | null>(null);
|
|
const [masked, setMasked] = useState<string[]>([]);
|
|
const [saving, setSaving] = useState<string | null>(null);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
|
const [revealing, setRevealing] = useState<string | null>(null);
|
|
const [activated, setActivated] = useState<boolean | null>(null);
|
|
const [activationLoading, setActivationLoading] = useState(false);
|
|
const [activationDialogOpen, setActivationDialogOpen] = useState(false);
|
|
const [onion, setOnion] = useState("");
|
|
const [lanUrl, setLanUrl] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!isSuperAdmin) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await fetch("/api/activation");
|
|
if (!res.ok) throw new Error("Failed to fetch activation");
|
|
const data = await res.json();
|
|
if (!cancelled) setActivated(!!data.activated);
|
|
} catch {
|
|
if (!cancelled) setActivated(null);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isSuperAdmin]);
|
|
|
|
useEffect(() => {
|
|
if (!isSuperAdmin) return;
|
|
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 || "");
|
|
setLanUrl(data.lanUrl || "");
|
|
} catch {
|
|
if (cancelled) return;
|
|
setOnion("");
|
|
setLanUrl("");
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isSuperAdmin]);
|
|
|
|
const copyText = async (text: string, label: string) => {
|
|
if (!text) return;
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
toast.success(`${label} copied`);
|
|
} catch {
|
|
toast.error(`Failed to copy ${label}`);
|
|
}
|
|
};
|
|
|
|
const handleToggleActivation = async () => {
|
|
if (activated === null) return;
|
|
const next = !activated;
|
|
setActivationLoading(true);
|
|
try {
|
|
const res = await fetch("/api/activation", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ activated: next }),
|
|
});
|
|
if (!res.ok) throw new Error("Failed to update activation");
|
|
const data = await res.json();
|
|
setActivated(!!data.activated);
|
|
toast.success(next ? "Shop activated" : "Shop blocked");
|
|
} catch {
|
|
toast.error("Failed to update shop activation");
|
|
} finally {
|
|
setActivationLoading(false);
|
|
setActivationDialogOpen(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetch("/api/settings")
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
const m: string[] = data._masked || [];
|
|
setMasked(m);
|
|
delete data._masked;
|
|
setSettings(data);
|
|
})
|
|
.catch(() => toast.error("Failed to load settings"));
|
|
}, []);
|
|
|
|
const handleSave = async (key: string) => {
|
|
if (!settings) return;
|
|
setSaving(key);
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ key, value: settings[key] }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
toast.success(`${KEY_META[key]?.label || key} saved. Restart required.`);
|
|
} catch {
|
|
toast.error(`Failed to save ${KEY_META[key]?.label || key}`);
|
|
} finally {
|
|
setSaving(null);
|
|
}
|
|
};
|
|
|
|
const handleChange = (key: string, value: string) => {
|
|
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
|
|
};
|
|
|
|
// Показать/скрыть реальное значение секрета (глазик)
|
|
const handleReveal = async (key: string) => {
|
|
if (revealed[key] !== undefined) {
|
|
// Повторный клик — скрыть
|
|
setRevealed((prev) => {
|
|
const next = { ...prev };
|
|
delete next[key];
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
setRevealing(key);
|
|
try {
|
|
const res = await fetch("/api/settings", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ key }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
const data = await res.json();
|
|
setRevealed((prev) => ({ ...prev, [key]: data.value ?? "" }));
|
|
} catch {
|
|
toast.error(`Failed to reveal ${KEY_META[key]?.label || key}`);
|
|
} finally {
|
|
setRevealing(null);
|
|
}
|
|
};
|
|
|
|
const handleSwitchChange = (key: string, checked: boolean) => {
|
|
setSettings((prev) => (prev ? { ...prev, [key]: checked } : prev));
|
|
};
|
|
|
|
const handleExport = async () => {
|
|
setExporting(true);
|
|
try {
|
|
const res = await fetch("/api/settings/export");
|
|
if (!res.ok) throw new Error();
|
|
const data = await res.json();
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `telegram-shop-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
toast.success("Data exported successfully");
|
|
} catch {
|
|
toast.error("Failed to export data");
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const handleImport = async () => {
|
|
const file = fileInputRef.current?.files?.[0];
|
|
if (!file) return;
|
|
setImporting(true);
|
|
try {
|
|
const text = await file.text();
|
|
const data = JSON.parse(text);
|
|
const res = await fetch("/api/settings/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
const result = await res.json();
|
|
if (result.ok) {
|
|
toast.info(result.message || "Import not yet implemented");
|
|
} else {
|
|
toast.error(result.error || "Import failed");
|
|
}
|
|
} catch {
|
|
toast.error("Failed to import data. Ensure the file is valid JSON.");
|
|
} finally {
|
|
setImporting(false);
|
|
setImportDialogOpen(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="page-enter space-y-6">
|
|
|
|
<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">
|
|
Restart the application to apply changes.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<p className="text-sm text-muted-foreground">Configure bot settings, WireGuard VPN, and admin panel options. Changes require a restart.</p>
|
|
|
|
{!settings ? (
|
|
<div className="space-y-6">
|
|
{SECTIONS.map((s) => (
|
|
<Card key={s.title}>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<s.icon className="h-4 w-4" />
|
|
{s.title}
|
|
</CardTitle>
|
|
<CardDescription>{s.description}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<SkeletonForm />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{SECTIONS.map((section) => {
|
|
const SectionIcon = section.icon;
|
|
return (
|
|
<Card key={section.title}>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<SectionIcon className="h-4 w-4" />
|
|
{section.title}
|
|
</CardTitle>
|
|
<CardDescription>{section.description}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{section.keys.map((key) => {
|
|
const isMasked = masked.includes(key);
|
|
const isBool = key === "WG_ENABLED";
|
|
const value = String(settings[key] ?? "");
|
|
const meta = KEY_META[key];
|
|
|
|
return (
|
|
<div key={key} className="flex items-end gap-3">
|
|
<div className="flex-1 space-y-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<Label htmlFor={key} className="text-sm">
|
|
{meta?.label || key}
|
|
</Label>
|
|
{meta && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={4}>
|
|
{meta.description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
{meta && (
|
|
<p className="text-xs text-muted-foreground/60 mt-0.5">{meta.description}</p>
|
|
)}
|
|
{isBool ? (
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id={key}
|
|
checked={settings[key] === true}
|
|
onCheckedChange={(checked) => handleSwitchChange(key, checked)}
|
|
/>
|
|
<span className="text-sm text-muted-foreground">
|
|
{settings[key] ? "Enabled" : "Disabled"}
|
|
</span>
|
|
</div>
|
|
) : isMasked ? (
|
|
<div className="flex gap-2 max-w-md">
|
|
<Input
|
|
id={key}
|
|
value={revealed[key] !== undefined ? revealed[key] : MASKED_PLACEHOLDER}
|
|
disabled={revealed[key] === undefined}
|
|
onChange={(e) => handleChange(key, e.target.value)}
|
|
className="flex-1 font-mono"
|
|
type={revealed[key] !== undefined ? "text" : "password"}
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="outline"
|
|
className="shrink-0"
|
|
onClick={() => handleReveal(key)}
|
|
disabled={revealing === key}
|
|
title={revealed[key] !== undefined ? "Hide value" : "Show value"}
|
|
>
|
|
{revealing === key ? (
|
|
<span className="h-4 w-4 animate-pulse">...</span>
|
|
) : revealed[key] !== undefined ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Input
|
|
id={key}
|
|
value={value}
|
|
onChange={(e) => handleChange(key, e.target.value)}
|
|
className="max-w-md"
|
|
/>
|
|
)}
|
|
</div>
|
|
{!isMasked && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleSave(key)}
|
|
disabled={saving === key}
|
|
>
|
|
{saving === key ? (
|
|
"Saving..."
|
|
) : (
|
|
<>
|
|
<Save className="h-4 w-4 mr-1" />
|
|
Save
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Data Management Section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Database className="h-4 w-4 text-orange-500" />
|
|
Data Management
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Export all database records as JSON for backup, or import from a previous backup file.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleExport}
|
|
disabled={exporting}
|
|
className="gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
{exporting ? "Exporting..." : "Export All Data"}
|
|
</Button>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="h-4 w-4 text-muted-foreground/50 cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={4}>
|
|
Exports all 10 database tables including users, wallets, purchases, and settings.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{isSuperAdmin && (
|
|
<AlertDialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive" className="gap-2">
|
|
<Upload className="h-4 w-4" />
|
|
Import Data
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>\u26A0\uFE0F Confirm Data Import</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Importing data will overwrite existing records. This is a dangerous operation
|
|
that cannot be undone. Make sure you have a recent backup before proceeding.
|
|
Only super admins can perform this action.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="py-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json"
|
|
className="block w-full text-sm text-muted-foreground
|
|
file:mr-4 file:py-2 file:px-4
|
|
file:rounded-md file:border-0
|
|
file:text-sm file:font-semibold
|
|
file:bg-orange-50 file:text-orange-700
|
|
hover:file:bg-orange-100
|
|
dark:file:bg-orange-950 dark:file:text-orange-300"
|
|
/>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleImport}
|
|
disabled={importing}
|
|
>
|
|
{importing ? "Importing..." : "Proceed with Import"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Shop Activation (super admin only) */}
|
|
{isSuperAdmin && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
|
Shop Activation
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Control whether the shop is active and accepting orders. Blocking the shop
|
|
immediately disables purchasing for all users.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
{activated === null ? (
|
|
<Badge variant="secondary">Checking...</Badge>
|
|
) : activated ? (
|
|
<Badge className="bg-emerald-500/15 text-emerald-400">Activated</Badge>
|
|
) : (
|
|
<Badge className="bg-red-500/15 text-red-400">Blocked</Badge>
|
|
)}
|
|
<span className="text-sm text-muted-foreground">
|
|
{activated === null
|
|
? "Loading activation status..."
|
|
: activated
|
|
? "The shop is currently active and accepting orders."
|
|
: "The shop is currently blocked. Users cannot place orders."}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">Onion:</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => copyText(onion, "Onion address")}
|
|
disabled={!onion}
|
|
title={onion ? "Copy onion address" : "Onion address not ready yet"}
|
|
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
|
>
|
|
{onion || "—"}
|
|
{onion && <Copy className="h-3 w-3 opacity-60" />}
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Link2 className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-muted-foreground">LAN:</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => copyText(lanUrl, "LAN URL")}
|
|
disabled={!lanUrl}
|
|
title={lanUrl ? "Copy LAN URL" : "LAN URL not ready yet"}
|
|
className="inline-flex items-center gap-1.5 rounded border border-border bg-muted/50 px-2 py-0.5 font-mono text-xs text-muted-foreground hover:bg-muted transition-colors disabled:opacity-50"
|
|
>
|
|
{lanUrl || "—"}
|
|
{lanUrl && <Copy className="h-3 w-3 opacity-60" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<AlertDialog open={activationDialogOpen} onOpenChange={setActivationDialogOpen}>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant={activated ? "destructive" : "default"}
|
|
className="gap-2"
|
|
disabled={activated === null || activationLoading}
|
|
>
|
|
<Power className="h-4 w-4" />
|
|
{activated ? "Block Shop" : "Activate Shop"}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{activated ? "Block Shop" : "Activate Shop"}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{activated
|
|
? "Blocking the shop will immediately prevent all users from placing orders. This action can be reversed at any time."
|
|
: "Activating the shop will allow users to place orders again. This action can be reversed at any time."}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleToggleActivation}
|
|
disabled={activationLoading}
|
|
className={activated ? "bg-destructive text-white hover:bg-destructive/90" : ""}
|
|
>
|
|
{activationLoading
|
|
? "Updating..."
|
|
: activated
|
|
? "Block Shop"
|
|
: "Activate Shop"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|