🎉 feat: initialization

This commit is contained in:
web
2025-11-26 19:56:16 -08:00
commit a801849fb2
553 changed files with 213088 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { formatBytes } from "@workspace/ui/utils/formatting";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
type DisplayType = "currency" | "traffic" | "number" | "trafficSpeed";
interface DisplayProps<T> {
value?: T;
unlimited?: boolean;
type?: DisplayType;
}
export function Display<T extends number | undefined | null>({
value = 0,
unlimited = false,
type = "number",
}: DisplayProps<T>): string {
const { t } = useTranslation("components");
const { common } = useGlobalStore();
const { currency } = common;
if (type === "currency") {
const formattedValue = `${currency?.currency_symbol ?? ""}${unitConversion("centsToDollars", value as number)?.toFixed(2) ?? "0.00"}`;
return formattedValue;
}
if (
["traffic", "trafficSpeed", "number"].includes(type) &&
unlimited &&
!value
) {
return t("unlimited");
}
if (type === "traffic") {
return value ? formatBytes(value) : "0";
}
if (type === "trafficSpeed") {
return value ? `${formatBytes(value).replace("B", "b")}ps` : "0";
}
if (type === "number") {
return value ? value.toString() : "0";
}
return "0";
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { ExternalLink } from "lucide-react";
import type React from "react";
interface IpLinkProps {
ip: string;
children?: React.ReactNode;
className?: string;
target?: "_blank" | "_self";
}
export function IpLink({
ip,
children,
className = "",
target = "_blank",
}: IpLinkProps) {
const url = `https://ipinfo.io/${ip}`;
return (
<a
className={`inline-flex items-center gap-1 font-mono text-primary transition-colors hover:text-primary/80 hover:underline ${className}`}
href={url}
rel={target === "_blank" ? "noopener noreferrer" : undefined}
target={target}
>
{children || ip}
<ExternalLink className="h-3 w-3" />
</a>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Link } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
interface OrderLinkProps {
orderId?: string | number;
className?: string;
}
export function OrderLink({ orderId, className }: OrderLinkProps) {
if (!orderId) return <span>--</span>;
return (
<Button asChild className={`p-0 ${className || ""}`} variant="link">
<Link search={{ search: orderId }} to="/dashboard/order">
{orderId}
</Link>
</Button>
);
}
+10
View File
@@ -0,0 +1,10 @@
export const fallbackLng = "en-US";
export const supportedLngs = ["en-US", "zh-CN"];
export const CDN_URL =
import.meta.env.VITE_CDN_URL || "https://cdn.jsdmirror.com";
export const TUTORIAL_DOCUMENT =
import.meta.env.VITE_TUTORIAL_DOCUMENT || "true";
export const USER_EMAIL = import.meta.env.VITE_USER_EMAIL;
export const USER_PASSWORD = import.meta.env.VITE_USER_PASSWORD;
+56
View File
@@ -0,0 +1,56 @@
import { Link, useLocation } from "@tanstack/react-router";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@workspace/ui/components/breadcrumb";
import { Separator } from "@workspace/ui/components/separator";
import { SidebarTrigger } from "@workspace/ui/components/sidebar";
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
import { Fragment, useMemo } from "react";
import { findNavByUrl, useNavs } from "./navs";
import TimezoneSwitch from "./timezone-switch";
import { UserNav } from "./user-nav";
export function Header() {
const pathname = useLocation({ select: (location) => location.pathname });
const navs = useNavs();
const items = useMemo(() => findNavByUrl(navs, pathname), [pathname]);
return (
<header className="sticky top-0 z-50 flex h-14 shrink-0 items-center gap-2 bg-background">
<div className="flex flex-1 items-center gap-2 px-3">
<SidebarTrigger />
<Separator className="mr-2 h-4" orientation="vertical" />
<Breadcrumb>
<BreadcrumbList>
{items.map((item, index) => (
<Fragment key={item?.title}>
{index !== items.length - 1 && (
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={item?.url || "/dashboard"}>{item?.title}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
)}
{index < items.length - 1 && <BreadcrumbSeparator />}
{index === items.length - 1 && (
<BreadcrumbPage>{item?.title}</BreadcrumbPage>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex items-center gap-2 px-3">
<LanguageSwitch />
<TimezoneSwitch />
<ThemeSwitch />
<UserNav />
</div>
</header>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Outlet } from "@tanstack/react-router";
import {
SidebarInset,
SidebarProvider,
} from "@workspace/ui/components/sidebar";
import { getCookie } from "@workspace/ui/lib/cookies";
import { useEffect, useState } from "react";
import { Header } from "@/layout/header";
import { SidebarLeft } from "./sidebar-left";
export default function DashboardLayout() {
const [open, setOpen] = useState(true);
useEffect(() => {
const sidebarState = getCookie("sidebar_state");
if (sidebarState !== undefined) {
setOpen(sidebarState === "true");
}
}, []);
return (
<SidebarProvider defaultOpen={open}>
<SidebarLeft />
<SidebarInset className="relative flex-grow overflow-hidden">
<Header />
<div className="h-[calc(100vh-56px)] flex-grow gap-4 overflow-auto p-4">
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export interface NavItem {
title: string;
url?: string;
icon?: string;
items?: NavItem[];
defaultOpen?: boolean;
}
export function useNavs() {
const { t } = useTranslation("menu");
const navs: NavItem[] = useMemo(
() => [
{
title: t("Dashboard", "Dashboard"),
url: "/dashboard",
icon: "flat-color-icons:globe",
},
{
title: t("Maintenance", "Maintenance"),
icon: "flat-color-icons:data-protection",
items: [
{
title: t("Server Management", "Server Management"),
url: "/dashboard/servers",
icon: "flat-color-icons:data-protection",
},
{
title: t("Node Management", "Node Management"),
url: "/dashboard/nodes",
icon: "flat-color-icons:mind-map",
},
{
title: t("Subscribe Config", "Subscribe Config"),
url: "/dashboard/subscribe",
icon: "flat-color-icons:ruler",
},
{
title: t("Product Management", "Product Management"),
url: "/dashboard/product",
icon: "flat-color-icons:shop",
},
],
},
{
title: t("Commerce", "Commerce"),
icon: "flat-color-icons:sales-performance",
items: [
{
title: t("Order Management", "Order Management"),
url: "/dashboard/order",
icon: "flat-color-icons:todo-list",
},
{
title: t("Coupon Management", "Coupon Management"),
url: "/dashboard/coupon",
icon: "flat-color-icons:bookmark",
},
{
title: t("Marketing Management", "Marketing Management"),
url: "/dashboard/marketing",
icon: "flat-color-icons:bullish",
},
{
title: t("Announcement Management", "Announcement Management"),
url: "/dashboard/announcement",
icon: "flat-color-icons:advertising",
},
],
},
{
title: t("Users & Support", "Users & Support"),
icon: "flat-color-icons:collaboration",
items: [
{
title: t("User Management", "User Management"),
url: "/dashboard/user",
icon: "flat-color-icons:conference-call",
},
{
title: t("Ticket Management", "Ticket Management"),
url: "/dashboard/ticket",
icon: "flat-color-icons:collaboration",
},
{
title: t("Document Management", "Document Management"),
url: "/dashboard/document",
icon: "flat-color-icons:document",
},
],
},
{
defaultOpen: false,
title: t("System", "System"),
icon: "flat-color-icons:services",
items: [
{
title: t("System Config", "System Config"),
url: "/dashboard/system",
icon: "flat-color-icons:services",
},
{
title: t("Auth Control", "Auth Control"),
url: "/dashboard/auth-control",
icon: "flat-color-icons:lock-portrait",
},
{
title: t("Payment Config", "Payment Config"),
url: "/dashboard/payment",
icon: "flat-color-icons:currency-exchange",
},
{
title: t("ADS Config", "ADS Config"),
url: "/dashboard/ads",
icon: "flat-color-icons:electrical-sensor",
},
],
},
{
defaultOpen: false,
title: t("Logs & Analytics", "Logs & Analytics"),
icon: "flat-color-icons:statistics",
items: [
{
title: t("Login", "Login"),
url: "/dashboard/log/login",
icon: "flat-color-icons:unlock",
},
{
title: t("Register", "Register"),
url: "/dashboard/log/register",
icon: "flat-color-icons:contacts",
},
{
title: t("Email", "Email"),
url: "/dashboard/log/email",
icon: "flat-color-icons:feedback",
},
{
title: t("Mobile", "Mobile"),
url: "/dashboard/log/mobile",
icon: "flat-color-icons:sms",
},
{
title: t("Subscribe", "Subscribe"),
url: "/dashboard/log/subscribe",
icon: "flat-color-icons:workflow",
},
{
title: t("Reset Subscribe", "Reset Subscribe"),
url: "/dashboard/log/reset-subscribe",
icon: "flat-color-icons:refresh",
},
{
title: t("Subscribe Traffic", "Subscribe Traffic"),
url: "/dashboard/log/subscribe-traffic",
icon: "flat-color-icons:statistics",
},
{
title: t("Server Traffic", "Server Traffic"),
url: "/dashboard/log/server-traffic",
icon: "flat-color-icons:statistics",
},
{
title: t("Traffic Details", "Traffic Details"),
url: "/dashboard/log/traffic-details",
icon: "flat-color-icons:combo-chart",
},
{
title: t("Balance", "Balance"),
url: "/dashboard/log/balance",
icon: "flat-color-icons:sales-performance",
},
{
title: t("Commission", "Commission"),
url: "/dashboard/log/commission",
icon: "flat-color-icons:debt",
},
{
title: t("Gift", "Gift"),
url: "/dashboard/log/gift",
icon: "flat-color-icons:donate",
},
],
},
],
[t]
);
return navs;
}
export function findNavByUrl(navs: NavItem[], url: string) {
function matchDynamicRoute(pattern: string, path: string): boolean {
const regexPattern = pattern
.replace(/:[^/]+/g, "[^/]+")
.replace(/\//g, "\\/");
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(path);
}
function findNav(
items: NavItem[],
url: string,
path: NavItem[] = []
): NavItem[] {
for (const item of items) {
if (item.url === url || (item.url && matchDynamicRoute(item.url, url))) {
return [...path, item];
}
if (item.items) {
const result = findNav(item.items, url, [...path, item]);
if (result.length) return result;
}
}
return [];
}
return findNav(navs, url);
}
+294
View File
@@ -0,0 +1,294 @@
import { Link, useLocation } from "@tanstack/react-router";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@workspace/ui/components/sidebar";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import React, { useState } from "react";
import { useGlobalStore } from "@/stores/global";
import { type NavItem, useNavs } from "./navs";
function hasChildren(obj: any): obj is { items: any[] } {
return (
obj && Array.isArray((obj as any).items) && (obj as any).items.length > 0
);
}
export function SidebarLeft({
...props
}: React.ComponentProps<typeof Sidebar>) {
const { common } = useGlobalStore();
const { site } = common;
const navs = useNavs();
const pathname = useLocation({ select: (location) => location.pathname });
const { state, isMobile } = useSidebar();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
React.useEffect(() => {
setOpenGroups((prev) => {
const next: Record<string, boolean> = { ...prev };
navs.forEach((nav) => {
if (hasChildren(nav) && next[nav.title] === undefined) {
next[nav.title] = nav.defaultOpen ?? true;
}
});
return next;
});
}, [navs]);
const handleToggleGroup = (title: string) => {
setOpenGroups((prev) => ({ ...prev, [title]: !prev[title] }));
};
const normalize = (p: string) =>
p.endsWith("/") && p !== "/" ? p.replace(/\/+$/, "") : p;
const isActiveUrl = (url: string) => {
const path = normalize(pathname);
const target = normalize(url);
if (target === "/dashboard") return path === target;
if (path === target) return true;
return path.startsWith(`${target}/`);
};
const isGroupActive = (nav: NavItem) =>
(hasChildren(nav) && nav.items?.some((i: any) => isActiveUrl(i.url))) ||
("url" in nav && nav.url ? isActiveUrl(nav.url as string) : false);
React.useEffect(() => {
setOpenGroups((prev) => {
const next: Record<string, boolean> = { ...prev };
navs.forEach((nav) => {
if (hasChildren(nav) && isGroupActive(nav)) next[nav.title] = true;
});
return next;
});
}, [pathname, navs]);
const renderCollapsedFlyout = (nav: NavItem) => {
const ParentButton = (
<SidebarMenuButton
aria-label={nav.title}
className="h-8 justify-center"
isActive={false}
size="sm"
>
{"url" in nav && nav.url ? (
<Link to={nav.url as string}>
{"icon" in nav && (nav as any).icon ? (
<Icon className="size-4" icon={(nav as any).icon} />
) : null}
</Link>
) : "icon" in nav && (nav as any).icon ? (
<Icon className="size-4" icon={(nav as any).icon} />
) : null}
</SidebarMenuButton>
);
if (!hasChildren(nav)) return ParentButton;
return (
<HoverCard closeDelay={200} openDelay={40}>
<HoverCardTrigger asChild>{ParentButton}</HoverCardTrigger>
<HoverCardContent
align="start"
avoidCollisions
className="z-[9999] w-64 p-0"
collisionPadding={8}
side="right"
sideOffset={10}
>
<div className="flex items-center gap-2 border-b px-3 py-2">
{"icon" in nav && (nav as any).icon ? (
<Icon className="size-4" icon={(nav as any).icon} />
) : null}
<span className="truncate font-medium text-muted-foreground text-xs">
{nav.title}
</span>
</div>
<ul className="p-1">
{nav.items?.map((item: any) => (
<li key={item.title}>
<Link
className={[
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm",
isActiveUrl(item.url)
? "bg-accent text-accent-foreground"
: "hover:bg-accent/60",
].join(" ")}
to={item.url}
>
{item.icon && <Icon className="size-4" icon={item.icon} />}
<span className="truncate">{item.title}</span>
</Link>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
);
};
return (
<Sidebar className="border-r-0" collapsible="icon" {...props}>
<SidebarHeader className="p-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild className="h-10" size="sm">
<Link to="/">
<div className="flex aspect-square size-6 items-center justify-center rounded-lg">
<img
alt="logo"
className="size-full"
height={24}
src={site.site_logo || "/favicon.svg"}
width={24}
/>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold text-xs">
{site.site_name}
</span>
<span className="truncate text-xs opacity-70">
{site.site_desc}
</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="py-2">
<SidebarMenu>
{!isMobile && state === "collapsed"
? navs.map((nav) => (
<SidebarMenuItem className="mx-auto" key={nav.title}>
{renderCollapsedFlyout(nav)}
</SidebarMenuItem>
))
: navs.map((nav) => {
if (hasChildren(nav)) {
const isOpen = openGroups[nav.title] ?? false;
return (
<SidebarGroup className={cn("py-1")} key={nav.title}>
<SidebarMenuButton
className={cn(
"mb-2 flex h-8 w-full items-center justify-between hover:bg-accent/60 hover:text-accent-foreground"
)}
isActive={false}
onClick={() => handleToggleGroup(nav.title)}
size="sm"
style={{ fontWeight: 500 }}
tabIndex={0}
>
<span className="flex min-w-0 items-center gap-2">
{"icon" in nav && (nav as any).icon ? (
<Icon
className="size-4 shrink-0"
icon={(nav as any).icon}
/>
) : null}
<span className="truncate text-sm">{nav.title}</span>
</span>
<Icon
className={`ml-2 size-4 transition-transform ${isOpen ? "" : "-rotate-90"}`}
icon="mdi:chevron-down"
/>
</SidebarMenuButton>
{isOpen && (
<SidebarGroupContent className="px-4">
<SidebarMenu>
{nav.items?.map((item: any) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
className="h-8"
isActive={isActiveUrl(item.url)}
size="sm"
tooltip={item.title}
>
<Link to={item.url}>
{item.icon && (
<Icon
className="size-4"
icon={item.icon}
/>
)}
<span className="text-sm">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
)}
</SidebarGroup>
);
}
return (
<SidebarGroup className="py-1" key={nav.title}>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild={"url" in nav && !!(nav as any).url}
className="h-8"
isActive={
"url" in nav && (nav as any).url
? isActiveUrl((nav as any).url)
: false
}
size="sm"
tooltip={nav.title}
>
{"url" in nav && (nav as any).url ? (
<Link to={(nav as any).url}>
{"icon" in nav && (nav as any).icon ? (
<Icon
className="size-4"
icon={(nav as any).icon}
/>
) : null}
<span className="text-sm">{nav.title}</span>
</Link>
) : (
<>
{"icon" in nav && (nav as any).icon ? (
<Icon
className="size-4"
icon={(nav as any).icon}
/>
) : null}
<span className="text-sm">{nav.title}</span>
</>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</SidebarMenu>
</SidebarContent>
</Sidebar>
);
}
+315
View File
@@ -0,0 +1,315 @@
import { Button } from "@workspace/ui/components/button";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@workspace/ui/components/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@workspace/ui/components/popover";
import { Icon } from "@workspace/ui/composed/icon";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
interface TimezoneOption {
value: string;
label: string;
timezone: string;
}
function getCurrentTime(timezone: string): string {
try {
const now = new Date();
return now.toLocaleTimeString("en-US", {
timeZone: timezone,
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "--:--";
}
}
function getAllTimezones(locale = "en-US"): TimezoneOption[] {
try {
const timeZones = Intl.supportedValuesOf("timeZone");
const processed = timeZones
.map((tz) => {
try {
return {
value: tz,
label: tz,
timezone: getTimezoneOffset(tz),
};
} catch {
return {
value: tz,
label: tz,
timezone: "UTC+00:00",
};
}
})
.filter(Boolean)
.sort((a, b) => a.label.localeCompare(b.label, locale));
const hasUTC = processed.some((tz) => tz.value === "UTC");
if (!hasUTC) {
processed.unshift({
value: "UTC",
label: "UTC",
timezone: "UTC+00:00",
});
}
return processed;
} catch {
return [
{
value: "UTC",
label: "UTC",
timezone: "UTC+00:00",
},
];
}
}
function getServerTimezones(): string[] {
return ["UTC"];
}
function getRecommendedTimezones(): string[] {
try {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (browserTimezone.startsWith("Asia/")) {
return [
"Asia/Shanghai",
"Asia/Tokyo",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Seoul",
];
}
if (browserTimezone.startsWith("Europe/")) {
return [
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Rome",
"Europe/Madrid",
];
}
if (browserTimezone.startsWith("America/")) {
return [
"America/New_York",
"America/Los_Angeles",
"America/Chicago",
"America/Denver",
"America/Toronto",
];
}
if (browserTimezone.startsWith("Australia/")) {
return [
"Australia/Sydney",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Brisbane",
];
}
return [
"America/New_York",
"Europe/London",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
];
} catch {
return [
"America/New_York",
"Europe/London",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
];
}
}
function getTimezoneOffset(timezone: string): string {
try {
const now = new Date();
const utc = new Date(now.getTime() + now.getTimezoneOffset() * 60_000);
const targetTime = new Date(
utc.toLocaleString("en-US", { timeZone: timezone })
);
const offset = (targetTime.getTime() - utc.getTime()) / (1000 * 60 * 60);
const sign = offset >= 0 ? "+" : "-";
const hours = Math.floor(Math.abs(offset));
const minutes = Math.floor((Math.abs(offset) - hours) * 60);
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
} catch {
return "UTC+00:00";
}
}
export default function TimezoneSwitch() {
const { i18n } = useTranslation();
const locale = i18n.language;
const [timezone, setTimezone] = useState<string>("UTC");
const [open, setOpen] = useState(false);
const timezoneOptions = useMemo(() => getAllTimezones(locale), [locale]);
useEffect(() => {
const savedTimezone = localStorage.getItem("timezone");
if (savedTimezone) {
setTimezone(savedTimezone);
} else {
try {
const browserTimezone =
Intl.DateTimeFormat().resolvedOptions().timeZone;
setTimezone(browserTimezone);
localStorage.setItem("timezone", browserTimezone);
} catch {
setTimezone("UTC");
}
}
}, []);
const handleTimezoneChange = (newTimezone: string) => {
setTimezone(newTimezone);
localStorage.setItem("timezone", newTimezone);
setOpen(false);
window.dispatchEvent(
new CustomEvent("timezoneChanged", {
detail: { timezone: newTimezone },
})
);
};
const serverTimezones = timezoneOptions.filter(
(option) =>
getServerTimezones().includes(option.value) && option.value !== timezone
);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button className="p-0" size="icon" variant="ghost">
<Icon className="!size-6" icon="flat-color-icons:overtime" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandGroup heading="Current">
{timezoneOptions
.filter((option) => option.value === timezone)
.map((option) => (
<CommandItem
className="bg-primary/10"
key={option.value}
onSelect={() => handleTimezoneChange(option.value)}
value={`${option.label} ${option.value}`}
>
<div className="flex w-full items-center gap-3">
<div className="flex flex-1 flex-col">
<span className="font-medium">{option.value}</span>
<span className="text-muted-foreground text-xs">
{option.timezone} {getCurrentTime(option.value)}
</span>
</div>
<Icon className="h-4 w-4 opacity-100" icon="uil:check" />
</div>
</CommandItem>
))}
</CommandGroup>
{serverTimezones.length > 0 && (
<CommandGroup heading="Server">
{serverTimezones.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleTimezoneChange(option.value)}
value={`${option.label} ${option.value}`}
>
<div className="flex w-full items-center gap-3">
<div className="flex flex-1 flex-col">
<span className="font-medium">{option.value}</span>
<span className="text-muted-foreground text-xs">
{option.timezone} {getCurrentTime(option.value)}
</span>
</div>
<Icon className="h-4 w-4 opacity-0" icon="uil:check" />
</div>
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup heading="Recommended">
{timezoneOptions
.filter(
(option) =>
getRecommendedTimezones().includes(option.value) &&
option.value !== timezone
)
.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleTimezoneChange(option.value)}
value={`${option.label} ${option.value}`}
>
<div className="flex w-full items-center gap-3">
<div className="flex flex-1 flex-col">
<span className="font-medium">{option.value}</span>
<span className="text-muted-foreground text-xs">
{option.timezone} {getCurrentTime(option.value)}
</span>
</div>
<Icon className="h-4 w-4 opacity-0" icon="uil:check" />
</div>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="All">
{timezoneOptions
.filter(
(option) =>
!(
getServerTimezones().includes(option.value) ||
getRecommendedTimezones().includes(option.value)
) && option.value !== timezone
)
.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleTimezoneChange(option.value)}
value={`${option.label} ${option.value}`}
>
<div className="flex w-full items-center gap-3">
<div className="flex flex-1 flex-col">
<span className="font-medium">{option.value}</span>
<span className="text-muted-foreground text-xs">
{option.timezone} {getCurrentTime(option.value)}
</span>
</div>
<Icon className="h-4 w-4 opacity-0" icon="uil:check" />
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+73
View File
@@ -0,0 +1,73 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
import { Logout } from "@/utils/common";
export function UserNav() {
const { t } = useTranslation("auth");
const { user } = useGlobalStore();
if (user) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="default">
<Avatar className="size-8">
<AvatarImage alt={user?.avatar ?? ""} src={user?.avatar ?? ""} />
<AvatarFallback className="rounded-none bg-transparent">
{user?.auth_methods?.[0]?.auth_identifier
.toUpperCase()
.charAt(0)}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="font-medium text-sm leading-none">
{user?.auth_methods?.[0]?.auth_identifier}
</p>
{/* <p className='text-xs leading-none text-muted-foreground'>ID: {user?.id}</p> */}
</div>
</DropdownMenuLabel>
{/* <DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
Profile
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Billing
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
Settings
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup> */}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={Logout}>
{t("logout", "Logout")}
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}
+95
View File
@@ -0,0 +1,95 @@
import { createRouter, RouterProvider } from "@tanstack/react-router";
import {
TanStackQueryContext,
TanStackQueryProvider,
} from "@workspace/ui/integrations/tanstack-query";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
// Styles
import "@workspace/ui/globals.css";
import { DirectionProvider } from "@workspace/ui/integrations/direction";
import { LanguageProvider } from "@workspace/ui/integrations/language";
import { ThemeProvider } from "@workspace/ui/integrations/theme";
import { initializeI18n } from "@workspace/ui/lib/i18n";
import { fallbackLng, supportedLngs } from "./config/index.ts";
// Report web vitals
import reportWebVitals from "./reportWebVitals.ts";
// Common utilities
import { Logout } from "./utils/common.ts";
initializeI18n({
supportedLngs,
fallbackLng,
ns: [
"ads",
"announcement",
"auth-control",
"auth",
"components",
"coupon",
"dashboard",
"document",
"log",
"marketing",
"menu",
"nodes",
"order",
"payment",
"product",
"servers",
"subscribe",
"system",
"ticket",
"tool",
"translation",
"user",
],
});
window.logout = Logout;
// Create a new router instance
const TanStackQueryProviderContext = TanStackQueryContext();
const router = createRouter({
routeTree,
context: {
...TanStackQueryProviderContext,
},
defaultPreload: "intent",
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
});
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// Render the app
const rootElement = document.getElementById("app");
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<TanStackQueryProvider {...TanStackQueryProviderContext}>
<LanguageProvider supportedLanguages={supportedLngs}>
<ThemeProvider>
<DirectionProvider>
<RouterProvider router={router} />
</DirectionProvider>
</ThemeProvider>
</LanguageProvider>
</TanStackQueryProvider>
</StrictMode>
);
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+13
View File
@@ -0,0 +1,13 @@
const reportWebVitals = (onPerfEntry?: () => void) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
onCLS(onPerfEntry);
onINP(onPerfEntry);
onFCP(onPerfEntry);
onLCP(onPerfEntry);
onTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+799
View File
@@ -0,0 +1,799 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRouteImport } from './routes/__root'
const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')()
const IndexLazyRouteImport = createFileRoute('/')()
const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')()
const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')()
const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')()
const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')()
const DashboardTicketIndexLazyRouteImport =
createFileRoute('/dashboard/ticket/')()
const DashboardSystemIndexLazyRouteImport =
createFileRoute('/dashboard/system/')()
const DashboardSubscribeIndexLazyRouteImport = createFileRoute(
'/dashboard/subscribe/',
)()
const DashboardProductIndexLazyRouteImport = createFileRoute(
'/dashboard/product/',
)()
const DashboardPaymentIndexLazyRouteImport = createFileRoute(
'/dashboard/payment/',
)()
const DashboardOrderIndexLazyRouteImport =
createFileRoute('/dashboard/order/')()
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
'/dashboard/marketing/',
)()
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
'/dashboard/document/',
)()
const DashboardCouponIndexLazyRouteImport =
createFileRoute('/dashboard/coupon/')()
const DashboardAuthControlIndexLazyRouteImport = createFileRoute(
'/dashboard/auth-control/',
)()
const DashboardAnnouncementIndexLazyRouteImport = createFileRoute(
'/dashboard/announcement/',
)()
const DashboardAdsIndexLazyRouteImport = createFileRoute('/dashboard/ads/')()
const DashboardLogTrafficDetailsLazyRouteImport = createFileRoute(
'/dashboard/log/traffic-details',
)()
const DashboardLogSubscribeTrafficLazyRouteImport = createFileRoute(
'/dashboard/log/subscribe-traffic',
)()
const DashboardLogSubscribeLazyRouteImport = createFileRoute(
'/dashboard/log/subscribe',
)()
const DashboardLogServerTrafficLazyRouteImport = createFileRoute(
'/dashboard/log/server-traffic',
)()
const DashboardLogResetSubscribeLazyRouteImport = createFileRoute(
'/dashboard/log/reset-subscribe',
)()
const DashboardLogRegisterLazyRouteImport = createFileRoute(
'/dashboard/log/register',
)()
const DashboardLogMobileLazyRouteImport = createFileRoute(
'/dashboard/log/mobile',
)()
const DashboardLogLoginLazyRouteImport = createFileRoute(
'/dashboard/log/login',
)()
const DashboardLogGiftLazyRouteImport = createFileRoute('/dashboard/log/gift')()
const DashboardLogEmailLazyRouteImport = createFileRoute(
'/dashboard/log/email',
)()
const DashboardLogCommissionLazyRouteImport = createFileRoute(
'/dashboard/log/commission',
)()
const DashboardLogBalanceLazyRouteImport = createFileRoute(
'/dashboard/log/balance',
)()
const DashboardRouteLazyRoute = DashboardRouteLazyRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any).lazy(() =>
import('./routes/dashboard/route.lazy').then((d) => d.Route),
)
const IndexLazyRoute = IndexLazyRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const DashboardIndexLazyRoute = DashboardIndexLazyRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/index.lazy').then((d) => d.Route),
)
const DashboardServersLazyRoute = DashboardServersLazyRouteImport.update({
id: '/servers',
path: '/servers',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/servers.lazy').then((d) => d.Route),
)
const DashboardNodesLazyRoute = DashboardNodesLazyRouteImport.update({
id: '/nodes',
path: '/nodes',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/nodes.lazy').then((d) => d.Route),
)
const DashboardUserIndexLazyRoute = DashboardUserIndexLazyRouteImport.update({
id: '/user/',
path: '/user/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/user/index.lazy').then((d) => d.Route),
)
const DashboardTicketIndexLazyRoute =
DashboardTicketIndexLazyRouteImport.update({
id: '/ticket/',
path: '/ticket/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/ticket/index.lazy').then((d) => d.Route),
)
const DashboardSystemIndexLazyRoute =
DashboardSystemIndexLazyRouteImport.update({
id: '/system/',
path: '/system/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/system/index.lazy').then((d) => d.Route),
)
const DashboardSubscribeIndexLazyRoute =
DashboardSubscribeIndexLazyRouteImport.update({
id: '/subscribe/',
path: '/subscribe/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/subscribe/index.lazy').then((d) => d.Route),
)
const DashboardProductIndexLazyRoute =
DashboardProductIndexLazyRouteImport.update({
id: '/product/',
path: '/product/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/product/index.lazy').then((d) => d.Route),
)
const DashboardPaymentIndexLazyRoute =
DashboardPaymentIndexLazyRouteImport.update({
id: '/payment/',
path: '/payment/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/payment/index.lazy').then((d) => d.Route),
)
const DashboardOrderIndexLazyRoute = DashboardOrderIndexLazyRouteImport.update({
id: '/order/',
path: '/order/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/order/index.lazy').then((d) => d.Route),
)
const DashboardMarketingIndexLazyRoute =
DashboardMarketingIndexLazyRouteImport.update({
id: '/marketing/',
path: '/marketing/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
)
const DashboardDocumentIndexLazyRoute =
DashboardDocumentIndexLazyRouteImport.update({
id: '/document/',
path: '/document/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/document/index.lazy').then((d) => d.Route),
)
const DashboardCouponIndexLazyRoute =
DashboardCouponIndexLazyRouteImport.update({
id: '/coupon/',
path: '/coupon/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/coupon/index.lazy').then((d) => d.Route),
)
const DashboardAuthControlIndexLazyRoute =
DashboardAuthControlIndexLazyRouteImport.update({
id: '/auth-control/',
path: '/auth-control/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/auth-control/index.lazy').then((d) => d.Route),
)
const DashboardAnnouncementIndexLazyRoute =
DashboardAnnouncementIndexLazyRouteImport.update({
id: '/announcement/',
path: '/announcement/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/announcement/index.lazy').then((d) => d.Route),
)
const DashboardAdsIndexLazyRoute = DashboardAdsIndexLazyRouteImport.update({
id: '/ads/',
path: '/ads/',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/ads/index.lazy').then((d) => d.Route),
)
const DashboardLogTrafficDetailsLazyRoute =
DashboardLogTrafficDetailsLazyRouteImport.update({
id: '/log/traffic-details',
path: '/log/traffic-details',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/traffic-details.lazy').then((d) => d.Route),
)
const DashboardLogSubscribeTrafficLazyRoute =
DashboardLogSubscribeTrafficLazyRouteImport.update({
id: '/log/subscribe-traffic',
path: '/log/subscribe-traffic',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/subscribe-traffic.lazy').then(
(d) => d.Route,
),
)
const DashboardLogSubscribeLazyRoute =
DashboardLogSubscribeLazyRouteImport.update({
id: '/log/subscribe',
path: '/log/subscribe',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/subscribe.lazy').then((d) => d.Route),
)
const DashboardLogServerTrafficLazyRoute =
DashboardLogServerTrafficLazyRouteImport.update({
id: '/log/server-traffic',
path: '/log/server-traffic',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/server-traffic.lazy').then((d) => d.Route),
)
const DashboardLogResetSubscribeLazyRoute =
DashboardLogResetSubscribeLazyRouteImport.update({
id: '/log/reset-subscribe',
path: '/log/reset-subscribe',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/reset-subscribe.lazy').then((d) => d.Route),
)
const DashboardLogRegisterLazyRoute =
DashboardLogRegisterLazyRouteImport.update({
id: '/log/register',
path: '/log/register',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/register.lazy').then((d) => d.Route),
)
const DashboardLogMobileLazyRoute = DashboardLogMobileLazyRouteImport.update({
id: '/log/mobile',
path: '/log/mobile',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/mobile.lazy').then((d) => d.Route),
)
const DashboardLogLoginLazyRoute = DashboardLogLoginLazyRouteImport.update({
id: '/log/login',
path: '/log/login',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/login.lazy').then((d) => d.Route),
)
const DashboardLogGiftLazyRoute = DashboardLogGiftLazyRouteImport.update({
id: '/log/gift',
path: '/log/gift',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/gift.lazy').then((d) => d.Route),
)
const DashboardLogEmailLazyRoute = DashboardLogEmailLazyRouteImport.update({
id: '/log/email',
path: '/log/email',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/email.lazy').then((d) => d.Route),
)
const DashboardLogCommissionLazyRoute =
DashboardLogCommissionLazyRouteImport.update({
id: '/log/commission',
path: '/log/commission',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/commission.lazy').then((d) => d.Route),
)
const DashboardLogBalanceLazyRoute = DashboardLogBalanceLazyRouteImport.update({
id: '/log/balance',
path: '/log/balance',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/log/balance.lazy').then((d) => d.Route),
)
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
'/dashboard/log/email': typeof DashboardLogEmailLazyRoute
'/dashboard/log/gift': typeof DashboardLogGiftLazyRoute
'/dashboard/log/login': typeof DashboardLogLoginLazyRoute
'/dashboard/log/mobile': typeof DashboardLogMobileLazyRoute
'/dashboard/log/register': typeof DashboardLogRegisterLazyRoute
'/dashboard/log/reset-subscribe': typeof DashboardLogResetSubscribeLazyRoute
'/dashboard/log/server-traffic': typeof DashboardLogServerTrafficLazyRoute
'/dashboard/log/subscribe': typeof DashboardLogSubscribeLazyRoute
'/dashboard/log/subscribe-traffic': typeof DashboardLogSubscribeTrafficLazyRoute
'/dashboard/log/traffic-details': typeof DashboardLogTrafficDetailsLazyRoute
'/dashboard/ads': typeof DashboardAdsIndexLazyRoute
'/dashboard/announcement': typeof DashboardAnnouncementIndexLazyRoute
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
'/dashboard/product': typeof DashboardProductIndexLazyRoute
'/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
'/dashboard/user': typeof DashboardUserIndexLazyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
'/dashboard/log/email': typeof DashboardLogEmailLazyRoute
'/dashboard/log/gift': typeof DashboardLogGiftLazyRoute
'/dashboard/log/login': typeof DashboardLogLoginLazyRoute
'/dashboard/log/mobile': typeof DashboardLogMobileLazyRoute
'/dashboard/log/register': typeof DashboardLogRegisterLazyRoute
'/dashboard/log/reset-subscribe': typeof DashboardLogResetSubscribeLazyRoute
'/dashboard/log/server-traffic': typeof DashboardLogServerTrafficLazyRoute
'/dashboard/log/subscribe': typeof DashboardLogSubscribeLazyRoute
'/dashboard/log/subscribe-traffic': typeof DashboardLogSubscribeTrafficLazyRoute
'/dashboard/log/traffic-details': typeof DashboardLogTrafficDetailsLazyRoute
'/dashboard/ads': typeof DashboardAdsIndexLazyRoute
'/dashboard/announcement': typeof DashboardAnnouncementIndexLazyRoute
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
'/dashboard/product': typeof DashboardProductIndexLazyRoute
'/dashboard/subscribe': typeof DashboardSubscribeIndexLazyRoute
'/dashboard/system': typeof DashboardSystemIndexLazyRoute
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
'/dashboard/user': typeof DashboardUserIndexLazyRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexLazyRoute
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
'/dashboard/log/email': typeof DashboardLogEmailLazyRoute
'/dashboard/log/gift': typeof DashboardLogGiftLazyRoute
'/dashboard/log/login': typeof DashboardLogLoginLazyRoute
'/dashboard/log/mobile': typeof DashboardLogMobileLazyRoute
'/dashboard/log/register': typeof DashboardLogRegisterLazyRoute
'/dashboard/log/reset-subscribe': typeof DashboardLogResetSubscribeLazyRoute
'/dashboard/log/server-traffic': typeof DashboardLogServerTrafficLazyRoute
'/dashboard/log/subscribe': typeof DashboardLogSubscribeLazyRoute
'/dashboard/log/subscribe-traffic': typeof DashboardLogSubscribeTrafficLazyRoute
'/dashboard/log/traffic-details': typeof DashboardLogTrafficDetailsLazyRoute
'/dashboard/ads/': typeof DashboardAdsIndexLazyRoute
'/dashboard/announcement/': typeof DashboardAnnouncementIndexLazyRoute
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
'/dashboard/product/': typeof DashboardProductIndexLazyRoute
'/dashboard/subscribe/': typeof DashboardSubscribeIndexLazyRoute
'/dashboard/system/': typeof DashboardSystemIndexLazyRoute
'/dashboard/ticket/': typeof DashboardTicketIndexLazyRoute
'/dashboard/user/': typeof DashboardUserIndexLazyRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/dashboard'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
| '/dashboard/log/email'
| '/dashboard/log/gift'
| '/dashboard/log/login'
| '/dashboard/log/mobile'
| '/dashboard/log/register'
| '/dashboard/log/reset-subscribe'
| '/dashboard/log/server-traffic'
| '/dashboard/log/subscribe'
| '/dashboard/log/subscribe-traffic'
| '/dashboard/log/traffic-details'
| '/dashboard/ads'
| '/dashboard/announcement'
| '/dashboard/auth-control'
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
| '/dashboard/product'
| '/dashboard/subscribe'
| '/dashboard/system'
| '/dashboard/ticket'
| '/dashboard/user'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
| '/dashboard/log/email'
| '/dashboard/log/gift'
| '/dashboard/log/login'
| '/dashboard/log/mobile'
| '/dashboard/log/register'
| '/dashboard/log/reset-subscribe'
| '/dashboard/log/server-traffic'
| '/dashboard/log/subscribe'
| '/dashboard/log/subscribe-traffic'
| '/dashboard/log/traffic-details'
| '/dashboard/ads'
| '/dashboard/announcement'
| '/dashboard/auth-control'
| '/dashboard/coupon'
| '/dashboard/document'
| '/dashboard/marketing'
| '/dashboard/order'
| '/dashboard/payment'
| '/dashboard/product'
| '/dashboard/subscribe'
| '/dashboard/system'
| '/dashboard/ticket'
| '/dashboard/user'
id:
| '__root__'
| '/'
| '/dashboard'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
| '/dashboard/log/email'
| '/dashboard/log/gift'
| '/dashboard/log/login'
| '/dashboard/log/mobile'
| '/dashboard/log/register'
| '/dashboard/log/reset-subscribe'
| '/dashboard/log/server-traffic'
| '/dashboard/log/subscribe'
| '/dashboard/log/subscribe-traffic'
| '/dashboard/log/traffic-details'
| '/dashboard/ads/'
| '/dashboard/announcement/'
| '/dashboard/auth-control/'
| '/dashboard/coupon/'
| '/dashboard/document/'
| '/dashboard/marketing/'
| '/dashboard/order/'
| '/dashboard/payment/'
| '/dashboard/product/'
| '/dashboard/subscribe/'
| '/dashboard/system/'
| '/dashboard/ticket/'
| '/dashboard/user/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
DashboardRouteLazyRoute: typeof DashboardRouteLazyRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardRouteLazyRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexLazyRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard/': {
id: '/dashboard/'
path: '/'
fullPath: '/dashboard/'
preLoaderRoute: typeof DashboardIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/servers': {
id: '/dashboard/servers'
path: '/servers'
fullPath: '/dashboard/servers'
preLoaderRoute: typeof DashboardServersLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/nodes': {
id: '/dashboard/nodes'
path: '/nodes'
fullPath: '/dashboard/nodes'
preLoaderRoute: typeof DashboardNodesLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/user/': {
id: '/dashboard/user/'
path: '/user'
fullPath: '/dashboard/user'
preLoaderRoute: typeof DashboardUserIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/ticket/': {
id: '/dashboard/ticket/'
path: '/ticket'
fullPath: '/dashboard/ticket'
preLoaderRoute: typeof DashboardTicketIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/system/': {
id: '/dashboard/system/'
path: '/system'
fullPath: '/dashboard/system'
preLoaderRoute: typeof DashboardSystemIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/subscribe/': {
id: '/dashboard/subscribe/'
path: '/subscribe'
fullPath: '/dashboard/subscribe'
preLoaderRoute: typeof DashboardSubscribeIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/product/': {
id: '/dashboard/product/'
path: '/product'
fullPath: '/dashboard/product'
preLoaderRoute: typeof DashboardProductIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/payment/': {
id: '/dashboard/payment/'
path: '/payment'
fullPath: '/dashboard/payment'
preLoaderRoute: typeof DashboardPaymentIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/order/': {
id: '/dashboard/order/'
path: '/order'
fullPath: '/dashboard/order'
preLoaderRoute: typeof DashboardOrderIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/marketing/': {
id: '/dashboard/marketing/'
path: '/marketing'
fullPath: '/dashboard/marketing'
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/document/': {
id: '/dashboard/document/'
path: '/document'
fullPath: '/dashboard/document'
preLoaderRoute: typeof DashboardDocumentIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/coupon/': {
id: '/dashboard/coupon/'
path: '/coupon'
fullPath: '/dashboard/coupon'
preLoaderRoute: typeof DashboardCouponIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/auth-control/': {
id: '/dashboard/auth-control/'
path: '/auth-control'
fullPath: '/dashboard/auth-control'
preLoaderRoute: typeof DashboardAuthControlIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/announcement/': {
id: '/dashboard/announcement/'
path: '/announcement'
fullPath: '/dashboard/announcement'
preLoaderRoute: typeof DashboardAnnouncementIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/ads/': {
id: '/dashboard/ads/'
path: '/ads'
fullPath: '/dashboard/ads'
preLoaderRoute: typeof DashboardAdsIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/traffic-details': {
id: '/dashboard/log/traffic-details'
path: '/log/traffic-details'
fullPath: '/dashboard/log/traffic-details'
preLoaderRoute: typeof DashboardLogTrafficDetailsLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/subscribe-traffic': {
id: '/dashboard/log/subscribe-traffic'
path: '/log/subscribe-traffic'
fullPath: '/dashboard/log/subscribe-traffic'
preLoaderRoute: typeof DashboardLogSubscribeTrafficLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/subscribe': {
id: '/dashboard/log/subscribe'
path: '/log/subscribe'
fullPath: '/dashboard/log/subscribe'
preLoaderRoute: typeof DashboardLogSubscribeLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/server-traffic': {
id: '/dashboard/log/server-traffic'
path: '/log/server-traffic'
fullPath: '/dashboard/log/server-traffic'
preLoaderRoute: typeof DashboardLogServerTrafficLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/reset-subscribe': {
id: '/dashboard/log/reset-subscribe'
path: '/log/reset-subscribe'
fullPath: '/dashboard/log/reset-subscribe'
preLoaderRoute: typeof DashboardLogResetSubscribeLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/register': {
id: '/dashboard/log/register'
path: '/log/register'
fullPath: '/dashboard/log/register'
preLoaderRoute: typeof DashboardLogRegisterLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/mobile': {
id: '/dashboard/log/mobile'
path: '/log/mobile'
fullPath: '/dashboard/log/mobile'
preLoaderRoute: typeof DashboardLogMobileLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/login': {
id: '/dashboard/log/login'
path: '/log/login'
fullPath: '/dashboard/log/login'
preLoaderRoute: typeof DashboardLogLoginLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/gift': {
id: '/dashboard/log/gift'
path: '/log/gift'
fullPath: '/dashboard/log/gift'
preLoaderRoute: typeof DashboardLogGiftLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/email': {
id: '/dashboard/log/email'
path: '/log/email'
fullPath: '/dashboard/log/email'
preLoaderRoute: typeof DashboardLogEmailLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/commission': {
id: '/dashboard/log/commission'
path: '/log/commission'
fullPath: '/dashboard/log/commission'
preLoaderRoute: typeof DashboardLogCommissionLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/log/balance': {
id: '/dashboard/log/balance'
path: '/log/balance'
fullPath: '/dashboard/log/balance'
preLoaderRoute: typeof DashboardLogBalanceLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
}
}
interface DashboardRouteLazyRouteChildren {
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
DashboardServersLazyRoute: typeof DashboardServersLazyRoute
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
DashboardLogEmailLazyRoute: typeof DashboardLogEmailLazyRoute
DashboardLogGiftLazyRoute: typeof DashboardLogGiftLazyRoute
DashboardLogLoginLazyRoute: typeof DashboardLogLoginLazyRoute
DashboardLogMobileLazyRoute: typeof DashboardLogMobileLazyRoute
DashboardLogRegisterLazyRoute: typeof DashboardLogRegisterLazyRoute
DashboardLogResetSubscribeLazyRoute: typeof DashboardLogResetSubscribeLazyRoute
DashboardLogServerTrafficLazyRoute: typeof DashboardLogServerTrafficLazyRoute
DashboardLogSubscribeLazyRoute: typeof DashboardLogSubscribeLazyRoute
DashboardLogSubscribeTrafficLazyRoute: typeof DashboardLogSubscribeTrafficLazyRoute
DashboardLogTrafficDetailsLazyRoute: typeof DashboardLogTrafficDetailsLazyRoute
DashboardAdsIndexLazyRoute: typeof DashboardAdsIndexLazyRoute
DashboardAnnouncementIndexLazyRoute: typeof DashboardAnnouncementIndexLazyRoute
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
DashboardProductIndexLazyRoute: typeof DashboardProductIndexLazyRoute
DashboardSubscribeIndexLazyRoute: typeof DashboardSubscribeIndexLazyRoute
DashboardSystemIndexLazyRoute: typeof DashboardSystemIndexLazyRoute
DashboardTicketIndexLazyRoute: typeof DashboardTicketIndexLazyRoute
DashboardUserIndexLazyRoute: typeof DashboardUserIndexLazyRoute
}
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardNodesLazyRoute: DashboardNodesLazyRoute,
DashboardServersLazyRoute: DashboardServersLazyRoute,
DashboardIndexLazyRoute: DashboardIndexLazyRoute,
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
DashboardLogEmailLazyRoute: DashboardLogEmailLazyRoute,
DashboardLogGiftLazyRoute: DashboardLogGiftLazyRoute,
DashboardLogLoginLazyRoute: DashboardLogLoginLazyRoute,
DashboardLogMobileLazyRoute: DashboardLogMobileLazyRoute,
DashboardLogRegisterLazyRoute: DashboardLogRegisterLazyRoute,
DashboardLogResetSubscribeLazyRoute: DashboardLogResetSubscribeLazyRoute,
DashboardLogServerTrafficLazyRoute: DashboardLogServerTrafficLazyRoute,
DashboardLogSubscribeLazyRoute: DashboardLogSubscribeLazyRoute,
DashboardLogSubscribeTrafficLazyRoute: DashboardLogSubscribeTrafficLazyRoute,
DashboardLogTrafficDetailsLazyRoute: DashboardLogTrafficDetailsLazyRoute,
DashboardAdsIndexLazyRoute: DashboardAdsIndexLazyRoute,
DashboardAnnouncementIndexLazyRoute: DashboardAnnouncementIndexLazyRoute,
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
DashboardProductIndexLazyRoute: DashboardProductIndexLazyRoute,
DashboardSubscribeIndexLazyRoute: DashboardSubscribeIndexLazyRoute,
DashboardSystemIndexLazyRoute: DashboardSystemIndexLazyRoute,
DashboardTicketIndexLazyRoute: DashboardTicketIndexLazyRoute,
DashboardUserIndexLazyRoute: DashboardUserIndexLazyRoute,
}
const DashboardRouteLazyRouteWithChildren =
DashboardRouteLazyRoute._addFileChildren(DashboardRouteLazyRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
DashboardRouteLazyRoute: DashboardRouteLazyRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+79
View File
@@ -0,0 +1,79 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { Toaster } from "@workspace/ui/components/sonner";
import { NavigationProgress } from "@workspace/ui/composed/navigation-progress";
import { TanStackQueryDevtools } from "@workspace/ui/integrations/tanstack-query-devtools";
import { getCookie } from "@workspace/ui/lib/cookies";
import { getGlobalConfig } from "@workspace/ui/services/common/common";
import { isBrowser } from "@workspace/ui/utils/index";
import { useEffect } from "react";
import { Helmet, HelmetProvider } from "react-helmet-async";
import { useGlobalStore } from "@/stores/global";
export const Route = createRootRouteWithContext()({
component: () => {
const { common, setCommon, getUserInfo } = useGlobalStore();
useEffect(() => {
const initializeApp = async () => {
try {
const configResponse = await getGlobalConfig();
if (configResponse.data?.data) {
setCommon(configResponse.data.data);
}
try {
if (getCookie("Authorization")) {
await getUserInfo();
}
} catch {
/* empty */
}
} catch (error) {
console.error("Failed to initialize app:", error);
}
};
initializeApp();
}, []);
const { site } = common;
const title = site.site_name || "Loading...";
const description = site.site_desc || "";
const keywords = site.keywords || "";
const logo = site.site_logo || "";
const url = isBrowser() ? window.location.href : "";
return (
<HelmetProvider>
<Helmet>
<title>{title}</title>
<meta content={description} name="description" />
<meta content={keywords} name="keywords" />
<link href={url} rel="canonical" />
<link href={logo} rel="icon" type="image/svg+xml" />
<link href={logo} rel="apple-touch-icon" sizes="180x180" />
<link href="/site.webmanifest" rel="manifest" />
</Helmet>
<NavigationProgress />
<Outlet />
<Toaster closeButton richColors />
<div
dangerouslySetInnerHTML={{ __html: common?.site.custom_html || "" }}
id="custom_html"
/>
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
</HelmetProvider>
);
},
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Ads from "@/sections/ads";
export const Route = createLazyFileRoute("/dashboard/ads/")({
component: Ads,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Announcement from "@/sections/announcement";
export const Route = createLazyFileRoute("/dashboard/announcement/")({
component: Announcement,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import AuthControl from "@/sections/auth-control";
export const Route = createLazyFileRoute("/dashboard/auth-control/")({
component: AuthControl,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Coupon from "@/sections/coupon";
export const Route = createLazyFileRoute("/dashboard/coupon/")({
component: Coupon,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Document from "@/sections/document";
export const Route = createLazyFileRoute("/dashboard/document/")({
component: Document,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Dashboard from "@/sections/dashboard";
export const Route = createLazyFileRoute("/dashboard/")({
component: Dashboard,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import BalanceLogPage from "@/sections/log/balance";
export const Route = createLazyFileRoute("/dashboard/log/balance")({
component: BalanceLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import CommissionLogPage from "@/sections/log/commission";
export const Route = createLazyFileRoute("/dashboard/log/commission")({
component: CommissionLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import EmailLogPage from "@/sections/log/email";
export const Route = createLazyFileRoute("/dashboard/log/email")({
component: EmailLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import GiftLogPage from "@/sections/log/gift";
export const Route = createLazyFileRoute("/dashboard/log/gift")({
component: GiftLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import LoginLogPage from "@/sections/log/login";
export const Route = createLazyFileRoute("/dashboard/log/login")({
component: LoginLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import MobileLogPage from "@/sections/log/mobile";
export const Route = createLazyFileRoute("/dashboard/log/mobile")({
component: MobileLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import RegisterLogPage from "@/sections/log/register";
export const Route = createLazyFileRoute("/dashboard/log/register")({
component: RegisterLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import ResetSubscribeLogPage from "@/sections/log/reset-subscribe";
export const Route = createLazyFileRoute("/dashboard/log/reset-subscribe")({
component: ResetSubscribeLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import ServerTrafficLogPage from "@/sections/log/server-traffic";
export const Route = createLazyFileRoute("/dashboard/log/server-traffic")({
component: ServerTrafficLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import SubscribeTrafficLogPage from "@/sections/log/subscribe-traffic";
export const Route = createLazyFileRoute("/dashboard/log/subscribe-traffic")({
component: SubscribeTrafficLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import SubscribeLogPage from "@/sections/log/subscribe";
export const Route = createLazyFileRoute("/dashboard/log/subscribe")({
component: SubscribeLogPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import TrafficDetailsPage from "@/sections/log/traffic-details";
export const Route = createLazyFileRoute("/dashboard/log/traffic-details")({
component: TrafficDetailsPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import MarketingPage from "@/sections/marketing";
export const Route = createLazyFileRoute("/dashboard/marketing/")({
component: MarketingPage,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Nodes from "@/sections/nodes";
export const Route = createLazyFileRoute("/dashboard/nodes")({
component: Nodes,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Order from "@/sections/order";
export const Route = createLazyFileRoute("/dashboard/order/")({
component: Order,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Payment from "@/sections/payment";
export const Route = createLazyFileRoute("/dashboard/payment/")({
component: Payment,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Product from "@/sections/product";
export const Route = createLazyFileRoute("/dashboard/product/")({
component: Product,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import DashboardLayout from "@/layout";
export const Route = createLazyFileRoute("/dashboard")({
component: DashboardLayout,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Servers from "@/sections/servers";
export const Route = createLazyFileRoute("/dashboard/servers")({
component: Servers,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Subscribe from "@/sections/subscribe";
export const Route = createLazyFileRoute("/dashboard/subscribe/")({
component: Subscribe,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import System from "@/sections/system";
export const Route = createLazyFileRoute("/dashboard/system/")({
component: System,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Ticket from "@/sections/ticket";
export const Route = createLazyFileRoute("/dashboard/ticket/")({
component: Ticket,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import User from "@/sections/user";
export const Route = createLazyFileRoute("/dashboard/user/")({
component: User,
});
+6
View File
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Auth from "@/sections/auth";
export const Route = createLazyFileRoute("/")({
component: Auth,
});
+328
View File
@@ -0,0 +1,328 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
const formSchema = z.object({
title: z.string(),
type: z.enum(["image", "video"]),
content: z.string(),
description: z.string(),
target_url: z.string().url(),
start_time: z.number(),
end_time: z.number(),
});
interface AdsFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function AdsForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: AdsFormProps<T>) {
const { t } = useTranslation("ads");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
} as any,
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
const type = form.watch("type");
const startTime = form.watch("start_time");
const renderContentField = () => (
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.content", "Content")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue("content", value);
}}
placeholder={
type === "image"
? "https://example.com/image.jpg"
: "https://example.com/video.mp4"
}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.title", "Title")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterTitle", "Enter title")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.type", "Type")}</FormLabel>
<FormControl>
<RadioGroup
className="flex gap-4"
defaultValue={field.value}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="image" />
</FormControl>
<FormLabel className="font-normal">
{t("form.typeImage", "Image")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="video" />
</FormControl>
<FormLabel className="font-normal">
{t("form.typeVideo", "Video")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{renderContentField()}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.description", "Description")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t(
"form.enterDescription",
"Enter description"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="target_url"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.targetUrl", "Target URL")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t(
"form.enterTargetUrl",
"Enter target URL"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="start_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.startTime", "Start Time")}</FormLabel>
<FormControl>
<EnhancedInput
min={Number(new Date().toISOString().slice(0, 16))}
onValueChange={(value) => {
const timestamp = value
? new Date(value).getTime()
: 0;
form.setValue(field.name, timestamp);
const endTime = form.getValues("end_time");
if (endTime && timestamp > endTime) {
form.setValue("end_time", "");
}
}}
placeholder={t(
"form.enterStartTime",
"Select start time"
)}
step="1"
type="datetime-local"
value={
field.value
? new Date(field.value).toISOString().slice(0, 16)
: ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="end_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.endTime", "End Time")}</FormLabel>
<FormControl>
<EnhancedInput
disabled={!startTime}
min={Number(
startTime
? new Date(startTime).toISOString().slice(0, 16)
: new Date().toISOString().slice(0, 16)
)}
onValueChange={(value) => {
const timestamp = value
? new Date(value).getTime()
: 0;
if (!startTime || timestamp < startTime) return;
form.setValue(field.name, timestamp);
}}
placeholder={t("form.enterEndTime", "Select end time")}
step="1"
type="datetime-local"
value={
field.value
? new Date(field.value).toISOString().slice(0, 16)
: ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createAds,
deleteAds,
getAdsList,
updateAds,
} from "@workspace/ui/services/admin/ads";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import AdsForm from "./ads-form";
export default function Ads() {
const { t } = useTranslation("ads");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Ads, Record<string, unknown>>
action={ref}
actions={{
render: (row) => [
<AdsForm<API.UpdateAdsRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateAds({ ...row, ...values });
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("editAds", "Edit Ad")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Are you sure you want to delete this ad? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteAds({ id: row.id });
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
}}
columns={[
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("status") === 1}
onCheckedChange={async (checked) => {
await updateAds({
...row.original,
status: checked ? 1 : 0,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "type",
header: t("type", "Type"),
cell: ({ row }) => {
const type = row.original.type;
return <Badge>{type}</Badge>;
},
},
{
accessorKey: "target_url",
header: t("targetUrl", "Target URL"),
},
{
accessorKey: "description",
header: t("form.description", "Description"),
},
{
accessorKey: "period",
header: t("validityPeriod", "Validity Period"),
cell: ({ row }) => {
const { start_time, end_time } = row.original;
return (
<>
{formatDate(start_time)} - {formatDate(end_time)}
</>
);
},
},
]}
header={{
toolbar: (
<AdsForm<API.CreateAdsRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createAds({
...values,
status: 0,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("createAds", "Create Ad")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "status",
placeholder: t("status", "Status"),
options: [
{ label: t("enabled", "Enabled"), value: "1" },
{ label: t("disabled", "Disabled"), value: "0" },
],
},
{
key: "search",
},
]}
request={async (pagination, filters) => {
const { data } = await getAdsList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,213 @@
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createAnnouncement,
deleteAnnouncement,
getAnnouncementList,
updateAnnouncement,
} from "@workspace/ui/services/admin/announcement";
import { format } from "date-fns";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import NoticeForm from "./notice-form";
export default function Page() {
const { t } = useTranslation("announcement");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Announcement, { enable: boolean; search: string }>
action={ref}
actions={{
render(row) {
return [
<NoticeForm<API.Announcement>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateAnnouncement({
...row,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("editAnnouncement", "Edit Announcement")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteAnnouncement({
id: row.id,
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
for (const element of rows) {
await deleteAnnouncement({
id: element.id!,
});
}
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "show",
header: t("show", "Show"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("show")}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
show: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "pinned",
header: t("pinned", "Pinned"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("pinned")}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
pinned: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "popup",
header: t("popup", "Popup"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("popup")}
onCheckedChange={async (checked) => {
await updateAnnouncement({
...row.original,
popup: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "content",
header: t("content", "Content"),
},
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
cell: ({ row }) =>
format(row.getValue("updated_at"), "yyyy-MM-dd HH:mm:ss"),
},
]}
header={{
title: t("announcementList", "Announcement List"),
toolbar: (
<NoticeForm<API.CreateAnnouncementRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createAnnouncement(values);
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createAnnouncement", "Create Announcement")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "enable",
placeholder: t("enable", "Enable"),
options: [
{ label: t("show", "Show"), value: "false" },
{ label: t("hide", "Hide"), value: "true" },
],
},
{ key: "search" },
]}
request={async (pagination, filter) => {
const { data } = await getAnnouncementList({
...pagination,
...filter,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,149 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
import { Icon } from "@workspace/ui/composed/icon";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
const formSchema = z.object({
title: z.string(),
content: z.string().optional(),
});
interface AnnouncementFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function AnnouncementForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: AnnouncementFormProps<T>) {
const { t } = useTranslation("announcement");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
content: "",
...initialValues,
},
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[800px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-4 pt-4"
id="notice-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.title", "Title")}</FormLabel>
<FormControl>
<Input
placeholder={t("form.titlePlaceholder", "Enter title")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.content", "Content")}</FormLabel>
<FormControl>
<MarkdownEditor
onChange={(value) => {
form.setValue(field.name, value || "");
}}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,318 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const appleSchema = z.object({
enabled: z.boolean(),
config: z
.object({
team_id: z.string().optional(),
key_id: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
redirect_url: z.string().optional(),
})
.optional(),
});
type AppleFormData = z.infer<typeof appleSchema>;
export default function AppleForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "apple"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "apple",
});
return data.data;
},
enabled: open,
});
const form = useForm<AppleFormData>({
resolver: zodResolver(appleSchema),
defaultValues: {
enabled: false,
config: {
team_id: "",
key_id: "",
client_id: "",
client_secret: "",
redirect_url: "",
},
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
config: {
team_id: data.config?.team_id || "",
key_id: data.config?.key_id || "",
client_id: data.config?.client_id || "",
client_secret: data.config?.client_secret || "",
redirect_url: data.config?.redirect_url || "",
},
});
}
}, [data, form]);
async function onSubmit(values: AppleFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
...values.config,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:apple" />
</div>
<div className="flex-1">
<p className="font-medium">{t("apple.title", "Apple Sign-In")}</p>
<p className="text-muted-foreground text-sm">
{t(
"apple.description",
"Authenticate users with Apple accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("apple.title", "Apple Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="apple-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"apple.enableDescription",
"When enabled, users can sign in with their Apple ID"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.team_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.teamId", "Team ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="ABCDE1FGHI"
value={field.value}
/>
</FormControl>
<FormDescription>
{t("apple.teamIdDescription", "Apple Developer Team ID")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.key_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.keyId", "Key ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="ABC1234567"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.keyIdDescription",
"Your private key ID from Apple Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("apple.clientId", "Service ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="com.your.app.service"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.clientIdDescription",
"Apple Service ID, available from Apple Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("apple.clientSecret", "Private Key")}
</FormLabel>
<FormControl>
<Textarea
className="h-20"
onChange={field.onChange}
placeholder={
"-----BEGIN PRIVATE KEY-----\nMIGTAgEA...\n-----END PRIVATE KEY-----"
}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.clientSecretDescription",
"Private key content (.p8 file) for authenticating with Apple"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.redirect_url"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("apple.redirectUri", "Redirect URL")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="https://your-domain.com"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"apple.redirectUriDescription",
"API address for redirect URL after successful Apple authentication. Do not end with /"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="apple-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,295 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { uid } from "radash";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const deviceSchema = z.object({
id: z.number(),
method: z.string(),
enabled: z.boolean(),
config: z
.object({
show_ads: z.boolean().optional(),
only_real_device: z.boolean().optional(),
enable_security: z.boolean().optional(),
security_secret: z.string().optional(),
})
.optional(),
});
type DeviceFormData = z.infer<typeof deviceSchema>;
export default function DeviceForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "device"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "device",
});
return data.data;
},
enabled: open,
});
const form = useForm<DeviceFormData>({
resolver: zodResolver(deviceSchema),
defaultValues: {
id: 0,
method: "device",
enabled: false,
config: {
show_ads: false,
only_real_device: false,
enable_security: false,
security_secret: "",
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: DeviceFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
function generateSecurityKey() {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
form.setValue("config.security_secret", formatted);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:devices" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("device.title", "Device Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t("device.description", "Authenticate users with device")}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("device.title", "Device Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="device-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("device.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.enableDescription",
"When enabled, users can sign in with device"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.show_ads"
render={({ field }) => (
<FormItem>
<FormLabel>{t("device.showAds", "Show Ads")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.showAdsDescription",
"When enabled, ads will be shown"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.only_real_device"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("device.blockVirtualMachine", "Block Virtual Machine")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.blockVirtualMachineDescription",
"Block virtual machine login, only allow real device"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_security"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("device.enableSecurity", "Enable Security")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"device.enableSecurityDescription",
"When enabled, application requests must carry communication key"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.security_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("device.communicationKey", "Communication Key")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="e.g., 12345678-1234-1234-1234-123456789abc"
suffix={
<div className="flex h-9 items-center text-nowrap bg-muted px-3">
<Icon
className="size-4 cursor-pointer"
icon="mdi:dice-multiple"
onClick={generateSecurityKey}
/>
</div>
}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"device.communicationKeyDescription",
"The key used for secure communication between application and server"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="device-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,865 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Textarea } from "@workspace/ui/components/textarea";
import { HTMLEditor } from "@workspace/ui/composed/editor/html";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
testEmailSend,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const emailSettingsSchema = z.object({
id: z.number(),
method: z.string(),
enabled: z.boolean(),
config: z
.object({
enable_verify: z.boolean(),
enable_domain_suffix: z.boolean(),
domain_suffix_list: z.string().optional(),
verify_email_template: z.string().optional(),
expiration_email_template: z.string().optional(),
maintenance_email_template: z.string().optional(),
traffic_exceed_email_template: z.string().optional(),
platform: z.string(),
platform_config: z
.object({
host: z.string().optional(),
port: z.number().optional(),
ssl: z.boolean(),
user: z.string().optional(),
pass: z.string().optional(),
from: z.string().optional(),
})
.optional(),
})
.optional(),
});
type EmailSettingsFormData = z.infer<typeof emailSettingsSchema>;
export default function EmailSettingsForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [testEmail, setTestEmail] = useState<string>();
const { data, refetch, isFetching } = useQuery({
queryKey: ["getAuthMethodConfig", "email"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "email",
});
return data.data;
},
enabled: open,
});
const form = useForm<EmailSettingsFormData>({
resolver: zodResolver(emailSettingsSchema),
defaultValues: {
id: 0,
method: "email",
enabled: false,
config: {
enable_verify: false,
enable_domain_suffix: false,
domain_suffix_list: "",
verify_email_template: "",
expiration_email_template: "",
maintenance_email_template: "",
traffic_exceed_email_template: "",
platform: "smtp",
platform_config: {
host: "",
port: 587,
ssl: false,
user: "",
pass: "",
from: "",
},
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: EmailSettingsFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...values,
config: {
...values.config,
platform: "smtp",
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:email-outline" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("email.title", "Email Settings")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"email.description",
"Configure email authentication and templates"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="md:!max-w-screen-lg max-w-full">
<SheetHeader>
<SheetTitle>{t("email.title", "Email Settings")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="email-settings-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<Tabs className="space-y-2" defaultValue="basic">
<TabsList className="flex h-full w-full flex-wrap *:flex-auto md:flex-nowrap">
<TabsTrigger value="basic">
{t("email.basicSettings", "Basic Settings")}
</TabsTrigger>
<TabsTrigger value="smtp">
{t("email.smtpSettings", "SMTP Settings")}
</TabsTrigger>
<TabsTrigger value="verify">
{t("email.verifyTemplate", "Verify Template")}
</TabsTrigger>
<TabsTrigger value="expiration">
{t("email.expirationTemplate", "Expiration Template")}
</TabsTrigger>
<TabsTrigger value="maintenance">
{t("email.maintenanceTemplate", "Maintenance Template")}
</TabsTrigger>
<TabsTrigger value="traffic">
{t("email.trafficTemplate", "Traffic Template")}
</TabsTrigger>
</TabsList>
<TabsContent className="space-y-2" value="basic">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("email.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.enableDescription",
"When enabled, users can sign in with email"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_verify"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.emailVerification", "Email Verification")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.emailVerificationDescription",
"Require email verification for new users"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_domain_suffix"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.emailSuffixWhitelist",
"Email Suffix Whitelist"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.emailSuffixWhitelistDescription",
"Only allow emails from whitelisted domains"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.domain_suffix_list"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.whitelistSuffixes", "Whitelist Suffixes")}
</FormLabel>
<FormControl>
<Textarea
className="h-32"
onChange={field.onChange}
placeholder={t(
"email.whitelistSuffixesPlaceholder",
"gmail.com, outlook.com"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.whitelistSuffixesDescription",
"One domain suffix per line"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="smtp">
<FormField
control={form.control}
name="config.platform_config.host"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpServerAddress", "SMTP Server Address")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpServerAddressDescription",
"The SMTP server hostname"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.port"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpServerPort", "SMTP Server Port")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
field.onChange(Number(value))
}
placeholder="587"
type="number"
value={field.value?.toString()}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpServerPortDescription",
"The SMTP server port (usually 25, 465, or 587)"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.ssl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.smtpEncryptionMethod",
"SSL/TLS Encryption"
)}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpEncryptionMethodDescription",
"Enable SSL/TLS encryption for SMTP connection"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.user"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpAccount", "SMTP Account")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpAccountDescription",
"The SMTP authentication username"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.pass"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.smtpPassword", "SMTP Password")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.smtpPasswordDescription",
"The SMTP authentication password"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.from"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email.senderAddress", "Sender Address")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"email.senderAddressDescription",
"The email address that appears in the From field"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2 border-t pt-4">
<FormLabel>
{t("email.sendTestEmail", "Send Test Email")}
</FormLabel>
<div className="flex items-center gap-2">
<EnhancedInput
onValueChange={(value) => setTestEmail(value as string)}
placeholder="test@example.com"
type="email"
value={testEmail}
/>
<Button
disabled={!testEmail || isFetching}
onClick={async () => {
if (!testEmail) return;
try {
await testEmailSend({ email: testEmail });
toast.success(
t("email.sendSuccess", "Email sent successfully")
);
} catch {
toast.error(
t("email.sendFailure", "Email send failed")
);
}
}}
type="button"
>
{t("email.sendTestEmail", "Send Test Email")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{t(
"email.sendTestEmailDescription",
"Send a test email to verify your SMTP configuration"
)}
</p>
</div>
</TabsContent>
<TabsContent className="space-y-2" value="verify">
<FormField
control={form.control}
name="config.verify_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.verifyEmailTemplate",
"Verify Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Type}}"}
</code>
<span>
{t(
"email.templateVariables.type.description",
"Email type (1: Register, 2: Reset Password)"
)}
</span>
</div>
<div className="pl-6 text-orange-600 dark:text-orange-400">
💡{" "}
{t(
"email.templateVariables.type.conditionalSyntax",
"Use conditional syntax to display different content"
)}
<br />
<code className="rounded bg-orange-50 px-1 text-xs dark:bg-orange-900/20">
{"{{if eq .Type 1}}...{{else}}...{{end}}"}
</code>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Expire}}"}
</code>
<span>
{t(
"email.templateVariables.expire.description",
"Code expiration time"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.Code}}"}
</code>
<span>
{t(
"email.templateVariables.code.description",
"Verification code"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="expiration">
<FormField
control={form.control}
name="config.expiration_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.expirationEmailTemplate",
"Expiration Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.ExpireDate}}"}
</code>
<span>
{t(
"email.templateVariables.expireDate.description",
"Subscription expiration date"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="maintenance">
<FormField
control={form.control}
name="config.maintenance_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.maintenanceEmailTemplate",
"Maintenance Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.MaintenanceDate}}"}
</code>
<span>
{t(
"email.templateVariables.maintenanceDate.description",
"Maintenance date"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.MaintenanceTime}}"}
</code>
<span>
{t(
"email.templateVariables.maintenanceTime.description",
"Maintenance time"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-2" value="traffic">
<FormField
control={form.control}
name="config.traffic_exceed_email_template"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"email.trafficExceedEmailTemplate",
"Traffic Exceed Email Template"
)}
</FormLabel>
<FormControl>
<HTMLEditor
onBlur={field.onChange}
placeholder={t(
"email.inputPlaceholder",
"Please enter"
)}
value={field.value}
/>
</FormControl>
<div className="mt-4 space-y-2 border-t pt-4">
<p className="font-medium text-muted-foreground text-sm">
{t(
"email.templateVariables.title",
"Template Variables"
)}
</p>
<div className="space-y-2 text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteLogo}}"}
</code>
<span>
{t(
"email.templateVariables.siteLogo.description",
"Site logo URL"
)}
</span>
</div>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">
{"{{.SiteName}}"}
</code>
<span>
{t(
"email.templateVariables.siteName.description",
"Site name"
)}
</span>
</div>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="email-settings-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,229 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const facebookSchema = z.object({
enabled: z.boolean(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
});
type FacebookFormData = z.infer<typeof facebookSchema>;
export default function FacebookForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "facebook"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "facebook",
});
return data.data;
},
enabled: open,
});
const form = useForm<FacebookFormData>({
resolver: zodResolver(facebookSchema),
defaultValues: {
enabled: false,
client_id: "",
client_secret: "",
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
client_id: data.config?.client_id || "",
client_secret: data.config?.client_secret || "",
});
}
}, [data, form]);
async function onSubmit(values: FacebookFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
client_id: values.client_id,
client_secret: values.client_secret,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:facebook" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("facebook.title", "Facebook Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"facebook.description",
"Authenticate users with Facebook accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("facebook.title", "Facebook Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="facebook-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("facebook.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"facebook.enableDescription",
"When enabled, users can sign in with their Facebook account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("facebook.clientId", "App ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="1234567890123456"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"facebook.clientIdDescription",
"Facebook App ID, available from Facebook Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("facebook.clientSecret", "App Secret")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="1234567890abcdef1234567890abcdef"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"facebook.clientSecretDescription",
"Facebook App Secret, available from Facebook Developer Portal"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="facebook-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,230 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const githubSchema = z.object({
enabled: z.boolean(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
});
type GithubFormData = z.infer<typeof githubSchema>;
export default function GithubForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "github"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "github",
});
return data.data;
},
enabled: open,
});
const form = useForm<GithubFormData>({
resolver: zodResolver(githubSchema),
defaultValues: {
enabled: false,
client_id: "",
client_secret: "",
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
client_id: data.config?.client_id || "",
client_secret: data.config?.client_secret || "",
});
}
}, [data, form]);
async function onSubmit(values: GithubFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
client_id: values.client_id,
client_secret: values.client_secret,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:github" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("github.title", "GitHub Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"github.description",
"Authenticate users with GitHub accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("github.title", "GitHub Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="github-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("github.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"github.enableDescription",
"When enabled, users can sign in with their GitHub account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("github.clientId", "Client ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="e.g., Iv1.1234567890abcdef"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"github.clientIdDescription",
"GitHub OAuth App Client ID, available from GitHub Developer Settings"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("github.clientSecret", "Client Secret")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="e.g., 1234567890abcdef1234567890abcdef12345678"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"github.clientSecretDescription",
"GitHub OAuth App Client Secret, available from GitHub Developer Settings"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="github-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,227 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const googleSchema = z.object({
id: z.number(),
method: z.string().default("google").optional(),
enabled: z.boolean().default(false).optional(),
config: z
.object({
client_id: z.string().optional(),
client_secret: z.string().optional(),
})
.optional(),
});
type GoogleFormData = z.infer<typeof googleSchema>;
export default function GoogleForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "google"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "google",
});
return data.data;
},
enabled: open,
});
const form = useForm<GoogleFormData>({
resolver: zodResolver(googleSchema),
defaultValues: {
id: 0,
method: "google",
enabled: false,
config: {
client_id: "",
client_secret: "",
},
},
});
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: GoogleFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:google" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("google.title", "Google Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"google.description",
"Authenticate users with Google accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("google.title", "Google Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="google-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("google.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"google.enableDescription",
"When enabled, users can sign in with their Google account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("google.clientId", "Client ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="123456789-abc123def456.apps.googleusercontent.com"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"google.clientIdDescription",
"Google OAuth Client ID, available from Google Cloud Console"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.client_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("google.clientSecret", "Client Secret")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="GOCSPX-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"google.clientSecretDescription",
"Google OAuth Client Secret, available from Google Cloud Console"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="google-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,622 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { Textarea } from "@workspace/ui/components/textarea";
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import TagInput from "@workspace/ui/composed/tag-input";
import {
getAuthMethodConfig,
getSmsPlatform,
testSmsSend,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const phoneSettingsSchema = z.object({
id: z.number(),
method: z.string(),
enabled: z.boolean(),
config: z
.object({
enable_whitelist: z.boolean().optional(),
whitelist: z.array(z.string()).optional(),
platform: z.string().optional(),
platform_config: z
.object({
access: z.string().optional(),
endpoint: z.string().optional(),
secret: z.string().optional(),
template_code: z.string().optional(),
sign_name: z.string().optional(),
phone_number: z.string().optional(),
template: z.string().optional(),
})
.optional(),
})
.optional(),
});
type PhoneSettingsFormData = z.infer<typeof phoneSettingsSchema>;
export default function PhoneSettingsForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [testParams, setTestParams] = useState<API.TestSmsSendRequest>({
telephone: "",
area_code: "1",
});
const { data, refetch, isFetching } = useQuery({
queryKey: ["getAuthMethodConfig", "mobile"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "mobile",
});
return data.data;
},
enabled: open,
});
const { data: platforms } = useQuery({
queryKey: ["getSmsPlatform"],
queryFn: async () => {
const { data } = await getSmsPlatform();
return data.data?.list;
},
enabled: open,
});
const form = useForm<PhoneSettingsFormData>({
resolver: zodResolver(phoneSettingsSchema),
defaultValues: {
id: 0,
method: "mobile",
enabled: false,
config: {
enable_whitelist: false,
whitelist: [],
platform: "",
platform_config: {
access: "",
endpoint: "",
secret: "",
template_code: "code",
sign_name: "",
phone_number: "",
template: "",
},
},
},
});
const selectedPlatform = platforms?.find(
(platform) => platform.platform === form.watch("config.platform")
);
const { platform_url, platform_field_description: platformConfig } =
selectedPlatform ?? {};
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data, form]);
async function onSubmit(values: PhoneSettingsFormData) {
setLoading(true);
try {
await updateAuthMethodConfig(values as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon
className="h-5 w-5 text-primary"
icon="mdi:phone-settings"
/>
</div>
<div className="flex-1">
<p className="font-medium">{t("phone.title", "SMS Settings")}</p>
<p className="text-muted-foreground text-sm">
{t("phone.description", "Configure SMS authentication")}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("phone.title", "SMS Settings")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="phone-settings-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("phone.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
disabled={isFetching}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"phone.enableTip",
"When enabled, users can sign in with their phone number"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.enable_whitelist"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.whitelistValidation", "Whitelist Validation")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"phone.whitelistValidationTip",
"Only allow phone numbers with whitelisted area codes"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.whitelist"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.whitelistAreaCode", "Whitelist Area Codes")}
</FormLabel>
<FormControl>
<TagInput
onChange={field.onChange}
placeholder="1, 852, 886, 888"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"phone.whitelistAreaCodeTip",
"Enter area codes separated by commas"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform"
render={({ field }) => (
<FormItem>
<FormLabel>{t("phone.platform", "SMS Platform")}</FormLabel>
<div className="flex items-center gap-1">
<FormControl>
<Select
disabled={isFetching}
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{platforms?.map((item) => (
<SelectItem
key={item.platform}
value={item.platform}
>
{item.platform}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{platform_url && (
<Button asChild size="sm">
<Link target="_blank" to={platform_url}>
{t("phone.applyPlatform", "Apply")}
</Link>
</Button>
)}
</div>
<FormDescription>
{t("phone.platformTip", "Select SMS service provider")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config.platform_config.access"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.accessLabel", "Access Key")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.access,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.access,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{platformConfig?.endpoint && (
<FormField
control={form.control}
name="config.platform_config.endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.endpointLabel", "Endpoint")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.endpoint,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.endpoint,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="config.platform_config.secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.secretLabel", "Secret Key")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.secret,
}
)}
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.secret,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{platformConfig?.template_code && (
<FormField
control={form.control}
name="config.platform_config.template_code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.templateCodeLabel", "Template Code")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.template_code,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.template_code,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.sign_name && (
<FormField
control={form.control}
name="config.platform_config.sign_name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.signNameLabel", "Sign Name")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.sign_name,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.sign_name,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.phone_number && (
<FormField
control={form.control}
name="config.platform_config.phone_number"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("phone.phoneNumberLabel", "Phone Number")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={isFetching}
onValueChange={field.onChange}
placeholder={t(
"phone.platformConfigTip",
"Please enter {{key}}",
{
key: platformConfig?.phone_number,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t("phone.platformConfigTip", "Please enter {{key}}", {
key: platformConfig?.phone_number,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{platformConfig?.code_variable && (
<FormField
control={form.control}
name="config.platform_config.template"
render={({ field }) => (
<FormItem>
<FormLabel>{t("phone.template", "Template")}</FormLabel>
<FormControl>
<Textarea
disabled={isFetching}
onChange={field.onChange}
placeholder={t(
"phone.placeholders.template",
"Use {{code}} for verification code",
{
code: platformConfig?.code_variable,
}
)}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"phone.templateTip",
"Use {{code}} variable for the verification code",
{
code: platformConfig?.code_variable,
}
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="space-y-4 border-t pt-4">
<div>
<FormLabel>{t("phone.testSms", "Test SMS")}</FormLabel>
<p className="mb-3 text-muted-foreground text-sm">
{t(
"phone.testSmsTip",
"Send a test SMS to verify configuration"
)}
</p>
<div className="flex items-center gap-2">
<AreaCodeSelect
onChange={(value) => {
if (value.phone) {
setTestParams((prev) => ({
...prev,
area_code: value.phone!,
}));
}
}}
value={testParams.area_code}
/>
<EnhancedInput
onValueChange={(value) => {
setTestParams((prev) => ({
...prev,
telephone: value as string,
}));
}}
placeholder={t("phone.testSmsPhone", "Phone number")}
value={testParams.telephone}
/>
<Button
disabled={
!(testParams.telephone && testParams.area_code) ||
isFetching
}
onClick={async () => {
if (
isFetching ||
!testParams.telephone ||
!testParams.area_code
)
return;
try {
await testSmsSend(testParams);
toast.success(
t("phone.sendSuccess", "SMS sent successfully")
);
} catch {
toast.error(t("phone.sendFailed", "SMS send failed"));
}
}}
type="button"
>
{t("phone.testSms", "Test SMS")}
</Button>
</div>
</div>
</div>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="phone-settings-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,230 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getAuthMethodConfig,
updateAuthMethodConfig,
} from "@workspace/ui/services/admin/authMethod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const telegramSchema = z.object({
enabled: z.boolean(),
bot: z.string().optional(),
bot_token: z.string().optional(),
});
type TelegramFormData = z.infer<typeof telegramSchema>;
export default function TelegramForm() {
const { t } = useTranslation("auth-control");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const { data, refetch } = useQuery({
queryKey: ["getAuthMethodConfig", "telegram"],
queryFn: async () => {
const { data } = await getAuthMethodConfig({
method: "telegram",
});
return data.data;
},
enabled: open,
});
const form = useForm<TelegramFormData>({
resolver: zodResolver(telegramSchema),
defaultValues: {
enabled: false,
bot: "",
bot_token: "",
},
});
useEffect(() => {
if (data) {
form.reset({
enabled: data.enabled,
bot: data.config?.bot || "",
bot_token: data.config?.bot_token || "",
});
}
}, [data, form]);
async function onSubmit(values: TelegramFormData) {
setLoading(true);
try {
await updateAuthMethodConfig({
...data,
enabled: values.enabled,
config: {
...data?.config,
bot: values.bot,
bot_token: values.bot_token,
},
} as API.UpdateAuthMethodConfigRequest);
toast.success(t("common.saveSuccess", "Saved successfully"));
refetch();
setOpen(false);
} catch (_error) {
toast.error(t("common.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:telegram" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("telegram.title", "Telegram Sign-In")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"telegram.description",
"Authenticate users with Telegram accounts"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("telegram.title", "Telegram Sign-In")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-24px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="telegram-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>{t("telegram.enable", "Enable")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"telegram.enableDescription",
"When enabled, users can sign in with their Telegram account"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot"
render={({ field }) => (
<FormItem>
<FormLabel>{t("telegram.clientId", "Bot ID")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="6123456789"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"telegram.clientIdDescription",
"Telegram Bot ID, available from @BotFather"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("telegram.clientSecret", "Bot Token")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder="6123456789:AAHn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"telegram.clientSecretDescription",
"Telegram Bot Token, available from @BotFather"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("common.cancel", "Cancel")}
</Button>
<Button disabled={loading} form="telegram-form" type="submit">
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("common.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,67 @@
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@workspace/ui/components/table";
import { useTranslation } from "react-i18next";
import AppleForm from "./forms/apple-form";
import DeviceForm from "./forms/device-form";
import EmailSettingsForm from "./forms/email-settings-form";
import FacebookForm from "./forms/facebook-form";
import GithubForm from "./forms/github-form";
import GoogleForm from "./forms/google-form";
import PhoneSettingsForm from "./forms/phone-settings-form";
import TelegramForm from "./forms/telegram-form";
export default function AuthControl() {
const { t } = useTranslation("auth-control");
const formSections = [
{
title: t("communicationMethods", "Communication Methods"),
forms: [
{ component: EmailSettingsForm },
{ component: PhoneSettingsForm },
],
},
{
title: t("socialAuthMethods", "Social Authentication Methods"),
forms: [
{ component: AppleForm },
{ component: GoogleForm },
{ component: FacebookForm },
{ component: GithubForm },
{ component: TelegramForm },
],
},
{
title: t("deviceAuthMethods", "Device Authentication Methods"),
forms: [{ component: DeviceForm }],
},
];
return (
<div className="space-y-8">
{formSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<h2 className="mb-4 font-semibold text-lg">{section.title}</h2>
<Table>
<TableBody>
{section.forms.map((form, formIndex) => {
const FormComponent = form.component;
return (
<TableRow key={formIndex}>
<TableCell>
<FormComponent />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
))}
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useNavigate } from "@tanstack/react-router";
import {
resetPassword,
userLogin,
userRegister,
} from "@workspace/ui/services/common/auth";
import type { ReactNode } from "react";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { USER_EMAIL, USER_PASSWORD } from "@/config";
import { useGlobalStore } from "@/stores/global";
import { getRedirectUrl, setAuthorization } from "@/utils/common";
import LoginForm from "./login-form";
import RegisterForm from "./register-form";
import ResetForm from "./reset-form";
export default function EmailAuthForm() {
const { t } = useTranslation("auth");
const navigate = useNavigate();
const { getUserInfo } = useGlobalStore();
const [type, setType] = useState<"login" | "register" | "reset">("login");
const [loading, startTransition] = useTransition();
const [initialValues, setInitialValues] = useState<{
email?: string;
password?: string;
}>({
email: USER_EMAIL,
password: USER_PASSWORD,
});
const handleFormSubmit = async (params: any) => {
const onLogin = async (token?: string) => {
if (!token) return;
setAuthorization(token);
await getUserInfo();
navigate({ to: getRedirectUrl() });
};
startTransition(async () => {
try {
switch (type) {
case "login": {
const login = await userLogin(params);
toast.success(t("login.success", "Login successful!"));
onLogin(login.data.data?.token);
break;
}
case "register": {
const create = await userRegister(params);
toast.success(t("register.success", "Registration successful!"));
onLogin(create.data.data?.token);
break;
}
case "reset":
await resetPassword(params);
toast.success(t("reset.success", "Password reset successful!"));
setType("login");
break;
}
} catch (_error) {
/* empty */
}
});
};
let UserForm: ReactNode = null;
switch (type) {
case "login":
UserForm = (
<LoginForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
setInitialValues={setInitialValues}
/>
);
break;
case "register":
UserForm = (
<RegisterForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
setInitialValues={setInitialValues}
/>
);
break;
case "reset":
UserForm = (
<ResetForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
setInitialValues={setInitialValues}
/>
);
break;
}
return (
<>
<div className="mb-11 text-center">
<h1 className="mb-3 font-bold text-2xl">
{t(`${type || "check"}.title`)}
</h1>
<div className="font-medium text-muted-foreground">
{t(`${type || "check"}.description`)}
</div>
</div>
{UserForm}
</>
);
}
@@ -0,0 +1,147 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function LoginForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify } = common;
const formSchema = z.object({
email: z.string().email(t("login.email", "Email")),
password: z.string(),
cf_token:
verify.enable_login_verify && verify.turnstile_site_key
? z.string()
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
}
});
return (
<>
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"login.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"login.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_login_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="login"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
</Button>
</form>
</Form>
<div className="mt-4 flex w-full justify-between text-sm">
<Button
className="p-0"
onClick={() => onSwitchForm("reset")}
type="button"
variant="link"
>
{t("login.forgotPassword", "Forgot Password?")}
</Button>
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("register");
}}
variant="link"
>
{t("login.registerAccount", "Register Account")}
</Button>
</div>
</>
);
}
@@ -0,0 +1,251 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function RegisterForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify, auth, invite } = common;
const handleCheckUser = async (email: string) => {
try {
if (!auth.email.enable_domain_suffix) return true;
const domain = email.split("@")[1];
const isValid = auth.email?.domain_suffix_list
.split("\n")
.includes(domain || "");
return isValid;
} catch (error) {
console.log("Error checking user:", error);
return false;
}
};
const formSchema = z
.object({
email: z
.string()
.email(t("register.email", "Email"))
.refine(handleCheckUser, {
message: t("register.whitelist", "Email domain not allowed"),
}),
password: z.string(),
repeat_password: z.string(),
code: auth.email.enable_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string().min(1) : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("register.passwordMismatch", "Passwords do not match"),
path: ["repeat_password"],
});
}
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
invite: localStorage.getItem("invite") || "",
},
});
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
}
});
return (
<>
{auth.register.stop_register ? (
<Markdown>{t("register.message", "Registration is disabled")}</Markdown>
) : (
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"register.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"register.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeat_password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder={t(
"register.repeatPasswordPlaceholder",
"Enter password again..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{auth.email.enable_verify && (
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder={t(
"register.codePlaceholder",
"Enter code..."
)}
type="text"
{...field}
value={field.value as string}
/>
<SendCode
params={{
...form.getValues(),
type: 1,
}}
type="email"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="invite"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading || !!localStorage.getItem("invite")}
placeholder={t("register.invite", "Invite Code")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_register_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="register"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")}
</Button>
</form>
</Form>
)}
<div className="mt-4 text-right text-sm">
{t("register.existingAccount", "Already have an account?")}&nbsp;
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("login");
}}
variant="link"
>
{t("register.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
@@ -0,0 +1,170 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import SendCode from "../send-code";
import CloudFlareTurnstile, { type TurnstileRef } from "../turnstile";
export default function ResetForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify, auth } = common;
const formSchema = z.object({
email: z.string().email(t("reset.email", "Email")),
password: z.string(),
code: auth?.email?.enable_verify ? z.string() : z.string().nullish(),
cf_token:
verify.enable_register_verify && verify.turnstile_site_key
? z.string()
: z.string().nullish(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
const turnstile = useRef<TurnstileRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
onSubmit(data);
} catch (_error) {
turnstile.current?.reset();
}
});
return (
<>
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"reset.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder={t("reset.codePlaceholder", "Enter code...")}
type="text"
{...field}
value={field.value as string}
/>
<SendCode
params={{
...form.getValues(),
type: 2,
}}
type="email"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"reset.passwordPlaceholder",
"Enter your new password..."
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="reset"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
</Button>
</form>
</Form>
<div className="mt-4 text-right text-sm">
{t("reset.existingAccount", "Remember your password?")}&nbsp;
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("login");
}}
variant="link"
>
{t("reset.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { Link, useNavigate } from "@tanstack/react-router";
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
import { useEffect } from "react";
import { useGlobalStore } from "@/stores/global";
import EmailAuthForm from "./email/auth-form";
export default function Auth() {
const { common, user } = useGlobalStore();
const { site } = common;
const navigate = useNavigate();
useEffect(() => {
if (user) {
// navigate({ to: "/dashboard" });
}
}, [navigate, user]);
return (
<main className="flex h-full min-h-screen items-center bg-muted/50">
<div className="flex size-full flex-auto flex-col justify-center lg:flex-row">
<div className="flex lg:w-1/2 lg:flex-auto">
<div className="flex w-full flex-col items-center justify-center px-5 py-4 md:px-14 lg:py-14">
<Link className="mb-0 flex flex-col items-center lg:mb-12" to="/">
<img
alt="logo"
height={48}
src={site.site_logo || "/favicon.svg"}
width={48}
/>
<span className="font-semibold text-2xl">{site.site_name}</span>
</Link>
<DotLottieReact
autoplay
className="mx-auto hidden w-full lg:block"
loop
src="./lotties/login.json"
/>
<p className="hidden w-[275px] text-center md:w-1/2 lg:block xl:w-[500px]">
{site.site_desc}
</p>
</div>
</div>
<div className="flex flex-initial justify-center p-8 lg:flex-auto lg:justify-end">
<div className="flex flex-col items-center rounded-2xl md:w-[600px] lg:flex-auto lg:bg-background lg:p-10 lg:shadow">
<div className="flex flex-col items-stretch justify-center md:w-[400px] lg:h-full">
<div className="flex flex-col justify-center pb-14 lg:flex-auto lg:pb-20">
<EmailAuthForm />
</div>
<div className="flex items-center justify-end">
{/* <div className='text-primary flex gap-5 text-sm font-semibold'>
<Link href='/tos'>{t('tos')}</Link>
</div> */}
<div className="flex items-center gap-5">
<LanguageSwitch />
<ThemeSwitch />
</div>
</div>
</div>
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,69 @@
import { Button } from "@workspace/ui/components/button";
import {
sendEmailCode,
sendSmsCode,
} from "@workspace/ui/services/common/common";
import { useCountDown } from "ahooks";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface SendCodeProps {
type: "email" | "phone";
params: {
email?: string;
type?: 1 | 2;
telephone_area_code?: string;
telephone?: string;
};
}
export default function SendCode({ type, params }: SendCodeProps) {
const { t } = useTranslation("auth");
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const getEmailCode = async () => {
if (params.email && params.type) {
await sendEmailCode({
email: params.email,
type: params.type,
});
setTargetDate(Date.now() + 60_000);
}
};
const getPhoneCode = async () => {
if (params.telephone && params.telephone_area_code && params.type) {
await sendSmsCode({
telephone: params.telephone,
telephone_area_code: params.telephone_area_code,
type: params.type,
});
setTargetDate(Date.now() + 60_000);
}
};
const handleSendCode = async () => {
if (type === "email") {
getEmailCode();
} else {
getPhoneCode();
}
};
const disabled =
seconds > 0 ||
(type === "email"
? !params.email
: !(params.telephone && params.telephone_area_code));
return (
<Button disabled={disabled} onClick={handleSendCode} type="button">
{seconds > 0 ? `${seconds}s` : t("get", "Get Code")}
</Button>
);
}
@@ -0,0 +1,63 @@
import { useTheme } from "next-themes";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import Turnstile, { useTurnstile } from "react-turnstile";
import { useGlobalStore } from "@/stores/global";
export type TurnstileRef = {
reset: () => void;
};
const CloudFlareTurnstile = forwardRef<
TurnstileRef,
{
id?: string;
value?: null | string;
onChange: (value?: string) => void;
}
>(function CloudFlareTurnstile({ id, value, onChange }, ref) {
const { common } = useGlobalStore();
const { verify } = common;
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const locale = i18n.language;
const turnstile = useTurnstile();
useImperativeHandle(
ref,
() => ({
reset: () => turnstile.reset(),
}),
[turnstile]
);
useEffect(() => {
if (value === "") {
turnstile.reset();
}
}, [turnstile, value]);
return (
verify.turnstile_site_key && (
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange();
turnstile.reset();
}}
onTimeout={() => {
onChange();
turnstile.reset();
}}
onVerify={(token) => onChange(token)}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
)
);
});
export default CloudFlareTurnstile;
@@ -0,0 +1,397 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Combobox } from "@workspace/ui/composed/combobox";
import { DatePicker } from "@workspace/ui/composed/date-picker";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useSubscribe } from "@/stores/subscribe";
const formSchema = z.object({
name: z.string(),
code: z.string().optional(),
count: z.number().optional(),
type: z.number().optional(),
discount: z.number().optional(),
start_time: z.number().optional(),
expire_time: z.number().optional(),
subscribe: z.array(z.number()).nullish(),
user_limit: z.number().optional(),
});
interface CouponFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function CouponForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: CouponFormProps<T>) {
const { t } = useTranslation("coupon");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
type: 1,
...initialValues,
} as any,
});
useEffect(() => {
form?.reset(initialValues);
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
const type = form.watch("type");
const { subscribes } = useSubscribe();
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t(
"form.enterCouponName",
"Enter Coupon Name"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.customCouponCode", "Custom Coupon Code")}
</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t(
"form.customCouponCodePlaceholder",
"Custom Coupon Code (leave blank for auto-generation)"
)}
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.type", "Coupon Type")}</FormLabel>
<FormControl>
<RadioGroup
className="flex gap-2"
defaultValue={String(field.value)}
onValueChange={(value) => {
form.setValue(field.name, Number(value));
form.setValue("discount", "");
}}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="1" />
</FormControl>
<FormLabel className="font-normal">
{t(
"form.percentageDiscount",
"Percentage Discount"
)}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="2" />
</FormControl>
<FormLabel className="font-normal">
{t("form.amountDiscount", "Amount Discount")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{type === 1 && (
<FormField
control={form.control}
name="discount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.percentageDiscount", "Percentage Discount")}
</FormLabel>
<FormControl>
<EnhancedInput
max={100}
min={1}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === 2 && (
<FormField
control={form.control}
name="discount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.amountDiscount", "Amount Discount")}
</FormLabel>
<FormControl>
<EnhancedInput
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="subscribe"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.specifiedServer", "Specified Subscription")}
</FormLabel>
<FormControl>
<Combobox<number, true>
multiple
onChange={(value) => {
form.setValue(field.name, value);
}}
options={subscribes?.map((item) => ({
value: item.id!,
label: item.name!,
}))}
placeholder={t(
"form.selectServer",
"Select Subscription"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="start_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.startTime", "Start Time")}</FormLabel>
<FormControl>
<DatePicker
disabled={(date: Date) =>
date < new Date(Date.now() - 24 * 60 * 60 * 1000)
}
onChange={(value: number | undefined) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expire_time"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.expireTime", "Expire Time")}</FormLabel>
<FormControl>
<DatePicker
onChange={(value: number | undefined) => {
form.setValue(field.name, value);
}}
placeholder={t("form.enterValue", "Enter Value")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="count"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.count", "Max Usage Count")}</FormLabel>
<FormControl>
<EnhancedInput
min={0}
placeholder={t(
"form.countPlaceholder",
"Max Usage Count (leave blank for no limit)"
)}
step={1}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("form.userLimit", "Max Usage Count per User")}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
placeholder={t(
"form.userLimitPlaceholder",
"Max Usage Count per User (leave blank for no limit)"
)}
step={1}
type="number"
{...field}
onValueChange={(value) => {
form.setValue(field.name, value);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
batchDeleteCoupon,
createCoupon,
deleteCoupon,
getCouponList,
updateCoupon,
} from "@workspace/ui/services/admin/coupon";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import CouponForm from "./coupon-form";
export default function Coupon() {
const { t } = useTranslation("coupon");
const [loading, setLoading] = useState(false);
const { subscribes } = useSubscribe();
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Coupon, { group_id: number; query: string }>
action={ref}
actions={{
render: (row) => [
<CouponForm<API.UpdateCouponRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateCoupon({ ...row, ...values });
toast.success(t("updateSuccess", "Update Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("editCoupon", "Edit Coupon")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Once deleted, data cannot be recovered. Please proceed with caution."
)}
key="delete"
onConfirm={async () => {
await deleteCoupon({ id: row.id });
toast.success(t("deleteSuccess", "Delete Success"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Are you sure you want to delete?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
batchRender: (rows) => [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Once deleted, data cannot be recovered. Please proceed with caution."
)}
key="delete"
onConfirm={async () => {
await batchDeleteCoupon({ ids: rows.map((item) => item.id) });
toast.success(t("deleteSuccess", "Delete Success"));
ref.current?.reset();
}}
title={t("confirmDelete", "Are you sure you want to delete?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
],
}}
columns={[
{
accessorKey: "enable",
header: t("enable", "Enable"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("enable")}
onCheckedChange={async (checked) => {
await updateCoupon({
...row.original,
enable: checked,
} as API.UpdateCouponRequest);
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "name",
header: t("name", "Name"),
},
{
accessorKey: "code",
header: t("code", "Code"),
},
{
accessorKey: "type",
header: t("type", "Type"),
cell: ({ row }) => (
<Badge
variant={row.getValue("type") === 1 ? "default" : "secondary"}
>
{row.getValue("type") === 1
? t("percentage", "Percentage")
: t("amount", "Amount")}
</Badge>
),
},
{
accessorKey: "discount",
header: t("discount", "Discount"),
cell: ({ row }) => (
<Badge
variant={row.getValue("type") === 1 ? "default" : "secondary"}
>
{row.getValue("type") === 1 ? (
`${row.original.discount} %`
) : (
<Display type="currency" value={row.original.discount} />
)}
</Badge>
),
},
{
accessorKey: "count",
header: t("count", "Count"),
cell: ({ row }) => (
<div className="flex flex-col">
<span>
{t("count", "Count")}:{" "}
{row.original.count === 0
? t("unlimited", "Unlimited")
: row.original.count}
</span>
<span>
{t("remainingTimes", "Remaining")}:{" "}
{row.original.count === 0
? t("unlimited", "Unlimited")
: row.original.count - row.original.used_count}
</span>
<span>
{t("usedTimes", "Usage Times")}: {row.original.used_count}
</span>
</div>
),
},
{
accessorKey: "expire",
header: t("validityPeriod", "Validity Period"),
cell: ({ row }) => {
const { start_time, expire_time } = row.original;
if (start_time) {
return expire_time ? (
<>
{formatDate(start_time)} - {formatDate(expire_time)}
</>
) : start_time ? (
formatDate(start_time)
) : (
"--"
);
}
return "--";
},
},
]}
header={{
toolbar: (
<CouponForm<API.CreateCouponRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createCoupon({
...values,
enable: false,
});
toast.success(t("createSuccess", "Create Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("createCoupon", "Create Coupon")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "subscribe",
placeholder: t("subscribe", "Subscribe"),
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{
key: "search",
},
]}
request={async (pagination, filters) => {
const { data } = await getCouponList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,109 @@
import { useQuery } from "@tanstack/react-query";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { useTranslation } from "react-i18next";
interface BillingProps {
type: "dashboard" | "payment";
}
interface ItemType {
logo: string;
title: string;
description: string;
expiryDate: string;
href: string;
}
async function getBillingURL() {
try {
const response = await fetch(
"https://api.github.com/repos/perfect-panel/ppanel-assets/commits"
);
const json = await response.json();
const version = json[0]?.sha || "latest";
const url = new URL(
"https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets"
);
url.pathname += `@${version}/billing/index.json`;
return url.toString();
} catch (_error) {
return "https://cdn.jsdmirror.com/gh/perfect-panel/ppanel-assets/billing/index.json";
}
}
export default function Billing({ type }: BillingProps) {
const { t } = useTranslation("dashboard");
const { data: list } = useQuery({
queryKey: ["billing", type],
queryFn: async () => {
const url = await getBillingURL();
const response = await fetch(url, {
headers: {
Accept: "application/json",
},
});
const data = await response.json();
const now = Date.now();
return Array.isArray(data[type])
? data[type].filter((item: { expiryDate: string }) => {
const expiryDate = Date.parse(item.expiryDate);
return !Number.isNaN(expiryDate) && expiryDate > now;
})
: [];
},
initialData: [],
});
if (!list?.length) return null;
return (
<>
<h1 className="text mt-2 font-bold">
<span>{t("billing.title", "Sponsor")}</span>
<span className="ml-2 text-muted-foreground text-xs">
{t(
"billing.description",
"Sponsoring helps PPanel to continue releasing updates!"
)}
</span>
</h1>
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
{list.map((item: ItemType, index: number) => (
<a
href={item.href}
key={index}
rel="noopener noreferrer"
target="_blank"
>
<Card className="h-full cursor-pointer">
<CardHeader className="flex flex-row gap-2 p-3">
<Avatar>
<AvatarImage src={item.logo} />
<AvatarFallback>{item.title}</AvatarFallback>
</Avatar>
<div>
<CardTitle>{item.title}</CardTitle>
<CardDescription className="mt-2">
{item.description}
</CardDescription>
</div>
</CardHeader>
</Card>
</a>
))}
</div>
</>
);
}
@@ -0,0 +1,457 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Empty } from "@workspace/ui/components/empty";
import { Separator } from "@workspace/ui/components/separator";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { queryRevenueStatistics } from "@workspace/ui/services/admin/console";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Label,
Pie,
PieChart,
XAxis,
} from "recharts";
import { Display } from "@/components/display";
export function RevenueStatisticsCard() {
const { t, i18n } = useTranslation("dashboard");
const locale = i18n.language;
const IncomeStatisticsConfig = {
new_purchase: {
label: t("newPurchase", "New Purchase"),
color: "var(--color-chart-1)",
},
repurchase: {
label: t("repurchase", "Repurchase"),
color: "var(--color-chart-2)",
},
total: {
label: t("totalIncome", "Total Income"),
color: "var(--color-chart-3)",
},
};
const { data: RevenueStatistics } = useQuery({
queryKey: ["queryRevenueStatistics"],
queryFn: async () => {
const { data } = await queryRevenueStatistics();
return data.data;
},
});
return (
<Tabs defaultValue="today">
<Card className="h-full pb-0">
<CardHeader className="!flex-row flex items-center justify-between">
<CardTitle>{t("revenueTitle", "Revenue Statistics")}</CardTitle>
<TabsList>
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
<TabsTrigger value="month">{t("month", "Month")}</TabsTrigger>
<TabsTrigger value="total">{t("total", "Total")}</TabsTrigger>
</TabsList>
</CardHeader>
<TabsContent className="h-full" value="today">
<CardContent className="h-80">
{RevenueStatistics?.today.new_order_amount ||
RevenueStatistics?.today.renewal_order_amount ? (
<ChartContainer
className="mx-auto max-h-80"
config={IncomeStatisticsConfig}
>
<PieChart>
<ChartLegend content={<ChartLegendContent />} />
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<Pie
data={[
{
type: "new_purchase",
value: unitConversion(
"centsToDollars",
RevenueStatistics?.today.new_order_amount
),
fill: "var(--color-new_purchase)",
},
{
type: "repurchase",
value: unitConversion(
"centsToDollars",
RevenueStatistics?.today.renewal_order_amount
),
fill: "var(--color-repurchase)",
},
]}
dataKey="value"
innerRadius={50}
nameKey="type"
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
dominantBaseline="middle"
textAnchor="middle"
x={viewBox.cx}
y={viewBox.cy}
>
<tspan
className="fill-foreground font-bold text-2xl"
x={viewBox.cx}
y={viewBox.cy}
>
{unitConversion(
"centsToDollars",
RevenueStatistics?.today.amount_total
)}
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex h-20 flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{t("totalIncome", "Total Income")}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.today.amount_total}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.today.new_order_amount}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.today.renewal_order_amount}
/>
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="month">
<CardContent className="h-80">
{RevenueStatistics?.monthly.list &&
RevenueStatistics?.monthly.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={IncomeStatisticsConfig}
>
<BarChart
accessibilityLayer
data={
RevenueStatistics?.monthly.list?.map((item) => ({
date: item.date,
new_purchase: unitConversion(
"centsToDollars",
item.new_order_amount
),
repurchase: unitConversion(
"centsToDollars",
item.renewal_order_amount
),
total: unitConversion(
"centsToDollars",
item.new_order_amount + item.renewal_order_amount
),
})) || []
}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month, day] = value.split("-");
return new Date(year, month - 1, day).toLocaleDateString(
locale,
{
month: "short",
day: "numeric",
}
);
}}
tickLine={false}
tickMargin={10}
/>
<Bar
dataKey="new_purchase"
fill="var(--color-new_purchase)"
radius={[0, 0, 4, 4]}
stackId="a"
/>
<Bar
dataKey="repurchase"
fill="var(--color-repurchase)"
radius={[4, 4, 0, 0]}
stackId="a"
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name, item, index) => (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] bg-[--color-bg]"
style={
{
"--color-bg": `var(--color-${name})`,
} as React.CSSProperties
}
/>
{IncomeStatisticsConfig[
name as keyof typeof IncomeStatisticsConfig
]?.label || name}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{value}
</div>
{index === 1 && (
<div className="flex basis-full items-center border-t pt-1.5 font-medium text-foreground text-xs">
{t("totalIncome", "Total Income")}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{item.payload.total}
</div>
</div>
)}
</>
)}
/>
}
cursor={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex h-20 flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{t("totalIncome", "Total Income")}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.monthly.amount_total}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.monthly.new_order_amount}
/>
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{IncomeStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.monthly.renewal_order_amount}
/>
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="total">
<CardContent className="h-80">
{RevenueStatistics?.all.list &&
RevenueStatistics?.all.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={IncomeStatisticsConfig}
>
<AreaChart
accessibilityLayer
data={
RevenueStatistics?.all.list?.map((item) => ({
date: item.date,
new_purchase: unitConversion(
"centsToDollars",
item.new_order_amount
),
repurchase: unitConversion(
"centsToDollars",
item.renewal_order_amount
),
total: unitConversion(
"centsToDollars",
item.new_order_amount + item.renewal_order_amount
),
})) || []
}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month] = value.split("-");
return new Date(year, month - 1).toLocaleDateString(
locale,
{
month: "short",
}
);
}}
tickLine={false}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name, item, index) => (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] bg-[--color-bg]"
style={
{
"--color-bg": `var(--color-${name})`,
} as React.CSSProperties
}
/>
{IncomeStatisticsConfig[
name as keyof typeof IncomeStatisticsConfig
]?.label || name}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{value}
</div>
{index === 1 && (
<div className="flex basis-full items-center border-t pt-1.5 font-medium text-foreground text-xs">
{t("totalIncome", "Total Income")}
<div className="ml-auto flex items-baseline gap-0.5 font-medium font-mono text-foreground tabular-nums">
{item.payload.total}
</div>
</div>
)}
</>
)}
/>
}
cursor={false}
/>
<Area
dataKey="new_purchase"
fill="var(--color-new_purchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-new_purchase)"
type="natural"
/>
<Area
dataKey="repurchase"
fill="var(--color-repurchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-repurchase)"
type="natural"
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex h-20 flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{t("totalIncome", "Total Income")}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
<Display
type="currency"
value={RevenueStatistics?.all.amount_total}
/>
</div>
</div>
</div>
</CardFooter>
</TabsContent>
</Card>
</Tabs>
);
}
@@ -0,0 +1,311 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Empty } from "@workspace/ui/components/empty";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Separator } from "@workspace/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { Icon } from "@workspace/ui/composed/icon";
import {
queryServerTotalData,
queryTicketWaitReply,
} from "@workspace/ui/services/admin/console";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Bar,
BarChart,
CartesianGrid,
LabelList,
XAxis,
YAxis,
} from "recharts";
import { UserSubscribeDetail } from "@/sections/user/user-detail";
import { RevenueStatisticsCard } from "./revenue-statistics-card";
import SystemVersionCard from "./system-version-card";
import { UserStatisticsCard } from "./user-statistics-card";
export default function Statistics() {
const { t } = useTranslation("dashboard");
const { data: TicketTotal } = useQuery({
queryKey: ["queryTicketWaitReply"],
queryFn: async () => {
const { data } = await queryTicketWaitReply();
return data.data?.count;
},
});
const { data: ServerTotal } = useQuery({
queryKey: ["queryServerTotalData"],
queryFn: async () => {
const { data } = await queryServerTotalData();
return data.data;
},
});
const [dataType, setDataType] = useState<string | "nodes" | "users">("nodes");
const [timeFrame, setTimeFrame] = useState<string | "today" | "yesterday">(
"today"
);
const trafficData = {
nodes: {
today:
ServerTotal?.server_traffic_ranking_today?.map((item) => ({
name: item.name,
traffic: item.download + item.upload,
})) || [],
yesterday:
ServerTotal?.server_traffic_ranking_yesterday?.map((item) => ({
name: item.name,
traffic: item.download + item.upload,
})) || [],
},
users: {
today:
ServerTotal?.user_traffic_ranking_today?.map((item) => ({
name: item.sid,
traffic: item.download + item.upload,
})) || [],
yesterday:
ServerTotal?.user_traffic_ranking_yesterday?.map((item) => ({
name: item.sid,
traffic: item.download + item.upload,
})) || [],
},
};
const currentData =
trafficData[dataType as "nodes" | "users"][
timeFrame as "today" | "yesterday"
];
return (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[
{
title: t("onlineUsersCount", "Online Users"),
value: ServerTotal?.online_users || 0,
subtitle: t("currentlyOnline", "Currently Online"),
icon: "uil:users-alt",
href: "/dashboard/user",
color: "text-blue-600 dark:text-blue-400",
iconBg: "bg-blue-100 dark:bg-blue-900/30",
},
{
title: t("todayTraffic", "Today Traffic"),
value: formatBytes(
(ServerTotal?.today_upload || 0) +
(ServerTotal?.today_download || 0)
),
subtitle: `${formatBytes(ServerTotal?.today_upload || 0)}${formatBytes(ServerTotal?.today_download || 0)}`,
icon: "uil:exchange-alt",
color: "text-purple-600 dark:text-purple-400",
iconBg: "bg-purple-100 dark:bg-purple-900/30",
},
{
title: t("monthTraffic", "Month Traffic"),
value: formatBytes(
(ServerTotal?.monthly_upload || 0) +
(ServerTotal?.monthly_download || 0)
),
subtitle: `${formatBytes(ServerTotal?.monthly_upload || 0)}${formatBytes(ServerTotal?.monthly_download || 0)}`,
icon: "uil:cloud-data-connection",
color: "text-orange-600 dark:text-orange-400",
iconBg: "bg-orange-100 dark:bg-orange-900/30",
},
{
title: t("totalServers", "Total Servers"),
value:
(ServerTotal?.online_servers || 0) +
(ServerTotal?.offline_servers || 0),
subtitle: `${t("online", "Online")} ${ServerTotal?.online_servers || 0} ${t("offline", "Offline")} ${ServerTotal?.offline_servers || 0}`,
icon: "uil:server-network",
href: "/dashboard/servers",
color: "text-green-600 dark:text-green-400",
iconBg: "bg-green-100 dark:bg-green-900/30",
},
{
title: t("pendingTickets", "Pending Tickets"),
value: TicketTotal || 0,
subtitle: t("pending", "Pending"),
icon: "uil:clipboard-notes",
href: "/dashboard/ticket",
color: "text-red-600 dark:text-red-400",
iconBg: "bg-red-100 dark:bg-red-900/30",
},
].map((item, index) => (
<Link
className={item.href ? "" : "pointer-events-none"}
key={index}
to={item.href || "#"}
>
<Card className={`group ${item.href ? "cursor-pointer" : ""}`}>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="mb-2 font-medium text-muted-foreground text-sm">
{item.title}
</p>
<div className={`mb-1 font-bold text-2xl ${item.color}`}>
{item.value}
</div>
<div className="h-4 text-muted-foreground text-xs">
{item.subtitle}
</div>
</div>
<div
className={`rounded-full p-3 ${item.iconBg} transition-transform duration-300 group-hover:scale-110`}
>
<Icon
className={`h-6 w-6 ${item.color}`}
icon={item.icon}
/>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
<SystemVersionCard />
</div>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
<RevenueStatisticsCard />
<UserStatisticsCard />
<Card>
<CardHeader className="!flex-row flex items-center justify-between">
<CardTitle>{t("trafficRank", "Traffic Rank")}</CardTitle>
<Tabs onValueChange={setTimeFrame} value={timeFrame}>
<TabsList>
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
<TabsTrigger value="yesterday">
{t("yesterday", "Yesterday")}
</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent className="h-80">
<div className="mb-6 flex items-center justify-between">
<h4 className="font-semibold">
{dataType === "nodes"
? t("nodeTraffic", "Node Traffic")
: t("userTraffic", "User Traffic")}
</h4>
<Select defaultValue="nodes" onValueChange={setDataType}>
<SelectTrigger className="w-28">
<SelectValue
placeholder={t("selectTypePlaceholder", "Select Type")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="nodes">{t("nodes", "Nodes")}</SelectItem>
<SelectItem value="users">{t("users", "Users")}</SelectItem>
</SelectContent>
</Select>
</div>
{currentData.length > 0 ? (
<ChartContainer
className="max-h-80"
config={{
traffic: {
label: t("traffic", "Traffic"),
color: "var(--primary)",
},
type: {
label: t("type", "Type"),
color: "var(--muted-foreground)",
},
label: {
color: "var(--foreground)",
},
}}
>
<BarChart data={currentData} height={400} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis
axisLine={false}
tickFormatter={(value) => formatBytes(value || 0)}
tickLine={false}
type="number"
/>
<YAxis
axisLine={false}
dataKey="name"
interval={0}
tickFormatter={(_value, index) => String(index + 1)}
tickLine={false}
tickMargin={0}
type="category"
width={15}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => formatBytes(Number(value) || 0)}
label={true}
labelFormatter={(label, [payload]) =>
dataType === "nodes" ? (
`${t("nodes", "Nodes")}: ${label}`
) : (
<>
<div className="w-80">
<UserSubscribeDetail
enabled={true}
id={payload?.payload.name}
/>
</div>
<Separator className="my-2" />
<div>{`${t("users", "Users")}: ${label}`}</div>
</>
)
}
/>
}
trigger="hover"
/>
<Bar
dataKey="traffic"
fill="var(--primary)"
radius={[0, 4, 4, 0]}
>
<LabelList
className="fill-[var(--foreground)]"
dataKey="name"
fontSize={12}
offset={8}
position="insideLeft"
/>
</Bar>
</BarChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}
@@ -0,0 +1,119 @@
import { useQuery } from "@tanstack/react-query";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { Icon } from "@workspace/ui/composed/icon";
import { getSystemLog } from "@workspace/ui/services/admin/tool";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface SystemLogsDialogProps {
trigger?: React.ReactNode;
variant?: "default" | "outline" | "ghost" | "secondary";
size?: "sm" | "default" | "lg";
}
export default function SystemLogsDialog({
trigger,
variant = "outline",
size = "sm",
}: SystemLogsDialogProps) {
const { t } = useTranslation("tool");
const [open, setOpen] = useState(false);
const {
data: logs,
refetch,
isLoading,
} = useQuery({
queryKey: ["getSystemLog"],
queryFn: async () => {
const { data } = await getSystemLog();
return data.data?.list || [];
},
enabled: open,
});
const defaultTrigger = (
<Button size={size} variant={variant}>
{t("systemLogs", "System Logs")}
</Button>
);
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>{trigger || defaultTrigger}</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t("systemLogs", "System Logs")}</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[60vh] max-h-[80vh] min-h-[400px] w-full rounded-lg border bg-muted/30 p-1">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Icon
className="h-8 w-8 animate-spin text-primary"
icon="uil:loading"
/>
</div>
) : (
<Accordion className="w-full" collapsible type="single">
{logs?.map((log: any, index: number) => (
<AccordionItem
className="px-4"
key={index}
value={`item-${index}`}
>
<AccordionTrigger className="hover:no-underline">
<div className="flex w-full flex-col items-start space-y-2 sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0">
<span className="font-medium text-xs sm:text-sm">
{log.timestamp}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-2">
{Object.entries(log).map(([key, value]) => (
<div
className="grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm"
key={key}
>
<span className="font-medium">{key}:</span>
<span className="break-all">{value as string}</span>
</div>
))}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</ScrollArea>
<DialogFooter>
<Button
onClick={() => {
refetch();
}}
variant="outline"
>
<Icon
className={`h-5 w-5 ${isLoading ? "animate-spin" : ""}`}
icon="uil:refresh"
/>
<span>{t("refreshLogs", "Refresh Logs")}</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,258 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Icon } from "@workspace/ui/composed/icon";
import { getVersion, restartSystem } from "@workspace/ui/services/admin/tool";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
import packageJson from "../../../../../../package.json";
import SystemLogsDialog from "./system-logs-dialog";
export default function SystemVersionCard() {
const { t } = useTranslation("tool");
const [openRestart, setOpenRestart] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const { data: versionInfo } = useQuery({
queryKey: ["getVersionInfo"],
queryFn: async () => {
try {
const [webResponse, serverResponse, systemResponse] = await Promise.all(
[
fetch(
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/ppanel-web/resolved?specifier=latest"
),
fetch(
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/server/resolved?specifier=latest"
),
getVersion(),
]
);
const webData = webResponse.ok ? await webResponse.json() : null;
const serverData = serverResponse.ok
? await serverResponse.json()
: null;
const systemData = systemResponse.data.data;
const rawVersion = (systemData?.version || "")
.replace(" Develop", "")
.trim();
const timeMatch = rawVersion.match(/\(([^)]+)\)/);
const timestamp = timeMatch ? timeMatch[1] : "";
const versionWithoutTime = rawVersion.replace(/\([^)]*\)/, "").trim();
const isDevelopment = !/^[Vv]?\d+\.\d+\.\d+(-[a-zA-Z]+(\.\d+)?)?$/.test(
versionWithoutTime
);
let displayVersion = versionWithoutTime;
if (
!(
isDevelopment ||
versionWithoutTime.startsWith("V") ||
versionWithoutTime.startsWith("v")
)
) {
displayVersion = `V${versionWithoutTime}`;
}
const lastUpdated = formatDate(new Date(timestamp || Date.now())) || "";
const systemInfo = {
isRelease: !isDevelopment,
version: displayVersion,
lastUpdated,
};
const latestReleases = {
web: webData
? {
version: webData.version,
url: `https://github.com/perfect-panel/ppanel-web/releases/tag/v${webData.version}`,
}
: null,
server: serverData
? {
version: serverData.version,
url: `https://github.com/perfect-panel/server/releases/tag/v${serverData.version}`,
}
: null,
};
const hasNewVersion =
latestReleases.web &&
packageJson.version !== latestReleases.web.version.replace(/^v/, "");
const hasServerNewVersion =
latestReleases.server &&
systemInfo.version &&
systemInfo.version.replace(/^V/, "") !==
latestReleases.server.version.replace(/^v/, "");
return {
systemInfo,
latestReleases,
hasNewVersion,
hasServerNewVersion,
};
} catch (error) {
console.error("Failed to fetch version info:", error);
return {
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
latestReleases: { web: null, server: null },
hasNewVersion: false,
hasServerNewVersion: false,
};
}
},
staleTime: 0,
retry: 1,
retryDelay: 10_000,
initialData: {
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
latestReleases: { web: null, server: null },
hasNewVersion: false,
hasServerNewVersion: false,
},
});
const { systemInfo, latestReleases, hasNewVersion, hasServerNewVersion } =
versionInfo;
return (
<Card className="gap-0 p-3">
<CardHeader className="mb-2 p-0">
<CardTitle className="flex items-center justify-between">
{t("systemServices", "System Services")}
<div className="flex items-center space-x-2">
<SystemLogsDialog size="sm" variant="outline" />
<AlertDialog onOpenChange={setOpenRestart} open={openRestart}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
{t("systemReboot", "System Reboot")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmSystemReboot", "Confirm System Reboot")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"rebootDescription",
"Are you sure you want to reboot the system? This action cannot be undone."
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel", "Cancel")}</AlertDialogCancel>
<Button
disabled={isRestarting}
onClick={async () => {
setIsRestarting(true);
await restartSystem();
await new Promise((resolve) => setTimeout(resolve, 5000));
setIsRestarting(false);
setOpenRestart(false);
}}
>
{isRestarting && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{isRestarting
? t("rebooting", "Rebooting...")
: t("confirmReboot", "Confirm Reboot")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-0">
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center">
<Icon className="mr-2 h-4 w-4 text-green-600" icon="mdi:web" />
<span className="font-medium text-sm">
{t("webVersion", "Web Version")}
</span>
</div>
<div className="flex items-center space-x-2">
<Badge>V{packageJson.version}</Badge>
{hasNewVersion && (
<Link
className="flex items-center space-x-1"
rel="noopener noreferrer"
target="_blank"
to={
latestReleases?.web?.url ||
"https://github.com/perfect-panel/ppanel-web/releases"
}
>
<Badge
className="animate-pulse px-2 py-0.5 text-xs"
variant="destructive"
>
{t("newVersionAvailable", "New Version Available")}
<Icon icon="mdi:open-in-new" />
</Badge>
</Link>
)}
</div>
</div>
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center">
<Icon className="mr-2 h-4 w-4 text-blue-600" icon="mdi:server" />
<span className="font-medium text-sm">
{t("serverVersion", "Server Version")}
</span>
</div>
<div className="flex items-center space-x-2">
<Badge variant={systemInfo?.isRelease ? "default" : "destructive"}>
{systemInfo?.version || "V1.0.0"}
</Badge>
{hasServerNewVersion && (
<Link
className="flex items-center space-x-1"
rel="noopener noreferrer"
target="_blank"
to={
latestReleases?.server?.url ||
"https://github.com/perfect-panel/server/releases"
}
>
<Badge
className="animate-pulse px-2 py-0.5 text-xs"
variant="destructive"
>
{t("newVersionAvailable", "New Version Available")}
<Icon icon="mdi:open-in-new" />
</Badge>
</Link>
)}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,373 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@workspace/ui/components/chart";
import { Empty } from "@workspace/ui/components/empty";
import { Separator } from "@workspace/ui/components/separator";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { queryUserStatistics } from "@workspace/ui/services/admin/console";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Label,
Pie,
PieChart,
XAxis,
} from "recharts";
export function UserStatisticsCard() {
const { t, i18n } = useTranslation("dashboard");
const locale = i18n.language;
const UserStatisticsConfig = {
register: {
label: t("register", "Register"),
color: "var(--color-chart-1)",
},
new_purchase: {
label: t("newPurchase", "New Purchase"),
color: "var(--color-chart-2)",
},
repurchase: {
label: t("repurchase", "Repurchase"),
color: "var(--color-chart-3)",
},
};
const { data: UserStatistics } = useQuery({
queryKey: ["queryUserStatistics"],
queryFn: async () => {
const { data } = await queryUserStatistics();
return data.data;
},
});
return (
<Tabs defaultValue="today">
<Card className="h-full pb-0">
<CardHeader className="!flex-row flex items-center justify-between">
<CardTitle>{t("userTitle", "User Statistics")}</CardTitle>
<TabsList>
<TabsTrigger value="today">{t("today", "Today")}</TabsTrigger>
<TabsTrigger value="month">{t("month", "Month")}</TabsTrigger>
<TabsTrigger value="total">{t("total", "Total")}</TabsTrigger>
</TabsList>
</CardHeader>
<TabsContent className="h-full" value="today">
<CardContent className="h-80">
{UserStatistics?.today.register ||
UserStatistics?.today.new_order_users ||
UserStatistics?.today.renewal_order_users ? (
<ChartContainer
className="mx-auto max-h-80"
config={UserStatisticsConfig}
>
<PieChart>
<ChartLegend content={<ChartLegendContent />} />
<ChartTooltip
content={<ChartTooltipContent hideLabel />}
cursor={false}
/>
<Pie
data={[
{
type: "register",
value: UserStatistics?.today.register || 0,
fill: "var(--color-register)",
},
{
type: "new_purchase",
value: UserStatistics?.today.new_order_users || 0,
fill: "var(--color-new_purchase)",
},
{
type: "repurchase",
value: UserStatistics?.today.renewal_order_users || 0,
fill: "var(--color-repurchase)",
},
]}
dataKey="value"
innerRadius={50}
nameKey="type"
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
const total =
(UserStatistics?.today.register || 0) +
(UserStatistics?.today.new_order_users || 0) +
(UserStatistics?.today.renewal_order_users || 0);
return (
<text
dominantBaseline="middle"
textAnchor="middle"
x={viewBox.cx}
y={viewBox.cy}
>
<tspan
className="fill-foreground font-bold text-3xl"
x={viewBox.cx}
y={viewBox.cy}
>
{total}
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.register.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.today.register}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.today.new_order_users}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.today.renewal_order_users}
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="month">
<CardContent className="h-80">
{UserStatistics?.monthly.list &&
UserStatistics?.monthly.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={UserStatisticsConfig}
>
<BarChart
accessibilityLayer
data={
UserStatistics?.monthly.list?.map((item) => ({
date: item.date,
register: item.register,
new_purchase: item.new_order_users,
repurchase: item.renewal_order_users,
})) || []
}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month, day] = value.split("-");
return new Date(year, month - 1, day).toLocaleDateString(
locale,
{
month: "short",
day: "numeric",
}
);
}}
tickLine={false}
tickMargin={10}
/>
<Bar
dataKey="register"
fill="var(--color-register)"
radius={[0, 0, 4, 4]}
stackId="a"
/>
<Bar
dataKey="new_purchase"
fill="var(--color-new_purchase)"
radius={0}
stackId="a"
/>
<Bar
dataKey="repurchase"
fill="var(--color-repurchase)"
radius={[4, 4, 0, 0]}
stackId="a"
/>
<ChartTooltip
content={<ChartTooltipContent />}
cursor={false}
/>
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.register.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.monthly.register}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.new_purchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.monthly.new_order_users}
</div>
</div>
<Separator className="!h-10 mx-2 w-px" orientation="vertical" />
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.repurchase.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.monthly.renewal_order_users}
</div>
</div>
</div>
</CardFooter>
</TabsContent>
<TabsContent className="h-full" value="total">
<CardContent className="h-80">
{UserStatistics?.all.list && UserStatistics?.all.list.length > 0 ? (
<ChartContainer
className="max-h-80 w-full"
config={UserStatisticsConfig}
>
<AreaChart
accessibilityLayer
data={
UserStatistics?.all.list?.map((item) => ({
date: item.date,
register: item.register,
new_purchase: item.new_order_users,
repurchase: item.renewal_order_users,
})) || []
}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
tickFormatter={(value) => {
const [year, month] = value.split("-");
return new Date(year, month - 1).toLocaleDateString(
locale,
{
month: "short",
}
);
}}
tickLine={false}
/>
<ChartTooltip
content={<ChartTooltipContent indicator="dot" />}
cursor={false}
/>
<Area
dataKey="register"
fill="var(--color-register)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-register)"
type="natural"
/>
<Area
dataKey="new_purchase"
fill="var(--color-new_purchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-new_purchase)"
type="natural"
/>
<Area
dataKey="repurchase"
fill="var(--color-repurchase)"
fillOpacity={0.4}
stackId="a"
stroke="var(--color-repurchase)"
type="natural"
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
) : (
<div className="flex h-full items-center justify-center">
<Empty />
</div>
)}
</CardContent>
<CardFooter className="!py-5 flex flex-row border-t">
<div className="flex w-full items-center gap-2">
<div className="grid flex-1 auto-rows-min gap-0.5">
<div className="text-muted-foreground text-xs">
{UserStatisticsConfig.register.label}
</div>
<div className="font-bold text-xl tabular-nums leading-none">
{UserStatistics?.all.register}
</div>
</div>
</div>
</CardFooter>
</TabsContent>
</Card>
</Tabs>
);
}
@@ -0,0 +1,11 @@
import Billing from "./components/billing";
import Statistics from "./components/statistics";
export default function Dashboard() {
return (
<div className="flex flex-1 flex-col gap-3">
<Statistics />
<Billing type="dashboard" />
</div>
);
}
@@ -0,0 +1,171 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
import { Icon } from "@workspace/ui/composed/icon";
import { TagInput } from "@workspace/ui/composed/tag-input";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
const formSchema = z.object({
title: z.string(),
tags: z.array(z.string()).nullish(),
content: z.string().nullish(),
});
interface DocumentFormProps<T> {
onSubmit: (data: T) => Promise<boolean> | boolean;
initialValues?: T;
loading?: boolean;
trigger: string;
title: string;
}
export default function DocumentForm<T extends Record<string, any>>({
onSubmit,
initialValues,
loading,
trigger,
title,
}: DocumentFormProps<T>) {
const { t } = useTranslation("document");
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
tags: [],
...initialValues,
} as any,
});
useEffect(() => {
form?.reset({
tags: [],
...initialValues,
});
}, [form, initialValues]);
async function handleSubmit(data: { [x: string]: any }) {
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[500px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.title", "Title")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"form.titlePlaceholder",
"Enter document title"
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.tags", "Tags")}</FormLabel>
<FormControl>
<TagInput
onChange={(value) => form.setValue(field.name, value)}
placeholder={t("form.tagsPlaceholder", "Enter tags")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.content", "Content")}</FormLabel>
<FormControl>
<MarkdownEditor
onChange={(value) => {
form.setValue(field.name, value);
}}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => {
setOpen(false);
}}
variant="outline"
>
{t("form.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}{" "}
{t("form.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+179
View File
@@ -0,0 +1,179 @@
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
batchDeleteDocument,
createDocument,
deleteDocument,
getDocumentList,
updateDocument,
} from "@workspace/ui/services/admin/document";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import DocumentForm from "./document-form";
export default function Page() {
const { t } = useTranslation("document");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.Document, { tag: string; search: string }>
action={ref}
actions={{
render(row) {
return [
<DocumentForm<API.UpdateDocumentRequest>
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateDocument({
...row,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("editDocument", "Edit Document")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"Are you sure you want to delete this document? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteDocument({
id: row.id,
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteDescription",
"Are you sure you want to delete this document? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await batchDeleteDocument({
ids: rows.map((item) => item.id),
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "show",
header: t("show", "Show"),
cell: ({ row }) => (
<Switch
defaultChecked={row.getValue("show")}
onCheckedChange={async (checked) => {
await updateDocument({
...row.original,
show: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "tags",
header: t("tags", "Tags"),
cell: ({ row }) => row.original.tags.join(", "),
},
{
accessorKey: "updated_at",
header: t("updatedAt", "Updated At"),
cell: ({ row }) => formatDate(row.getValue("updated_at")),
},
]}
header={{
title: t("DocumentList", "Document List"),
toolbar: (
<DocumentForm<API.CreateDocumentRequest>
key="create"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createDocument({
...values,
show: false,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
return true;
} catch {
return false;
} finally {
setLoading(false);
}
}}
title={t("createDocument", "Create Document")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "search",
},
{
key: "tag",
placeholder: t("tags", "Tags"),
},
]}
request={async (pagination, filter) => {
const { data } = await getDocumentList({ ...pagination, ...filter });
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterBalanceLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function BalanceLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
// i18n type declarations for extraction
// t("type.231", "Auto Reset")
// t("type.232", "Advance Reset")
// t("type.233", "Paid Reset")
// t("type.321", "Recharge")
// t("type.322", "Withdraw")
// t("type.323", "Payment")
// t("type.324", "Refund")
// t("type.325", "Reward")
// t("type.326", "Admin Adjust")
// t("type.331", "Purchase")
// t("type.332", "Renewal")
// t("type.333", "Refund")
// t("type.334", "Withdraw")
// t("type.335", "Admin Adjust")
// t("type.341", "Increase")
// t("type.342", "Reduce")
const getBalanceTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.BalanceLog, { search?: string }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "amount",
header: t("column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.original.amount} />
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "balance",
header: t("column.balance", "Balance"),
cell: ({ row }) => (
<Display type="currency" value={row.original.balance} />
),
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getBalanceTypeText(row.original.type)}</Badge>
),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.balance", "Balance Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterBalanceLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterCommissionLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function CommissionLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const getCommissionTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.CommissionLog, { search?: string }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "amount",
header: t("column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.original.amount} />
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getCommissionTypeText(row.original.type)}</Badge>
),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.commission", "Commission Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterCommissionLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterEmailLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
export default function EmailLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
search: sp.search || undefined,
date: sp.date || today,
};
return (
<ProTable<API.MessageLog, { search?: string }>
columns={[
{
accessorKey: "platform",
header: t("column.platform", "Platform"),
cell: ({ row }) => <Badge>{row.getValue("platform")}</Badge>,
},
{ accessorKey: "to", header: t("column.to", "To") },
{ accessorKey: "subject", header: t("column.subject", "Subject") },
{
accessorKey: "content",
header: t("column.content", "Content"),
cell: ({ row }) => (
<pre className="wrap-break-word max-w-[480px] overflow-auto whitespace-pre-wrap text-xs">
{JSON.stringify(row.original.content || {}, null, 2)}
</pre>
),
},
{
accessorKey: "status",
header: t("column.status", "Status"),
cell: ({ row }) => {
const status = row.original.status;
const getStatusVariant = (status: any) => {
if (status === 1) {
return "default";
}
if (status === 0) {
return "destructive";
}
return "outline";
};
const getStatusText = (status: any) => {
if (status === 1) return t("sent", "Sent");
if (status === 0) return t("failed", "Failed");
return t("unknown", "Unknown");
};
return (
<Badge variant={getStatusVariant(status)}>
{getStatusText(status)}
</Badge>
);
},
},
{
accessorKey: "created_at",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.created_at),
},
]}
header={{ title: t("title.email", "Email Log") }}
initialFilters={initialFilters}
params={[{ key: "search" }, { key: "date", type: "date" }]}
request={async (pagination, filter) => {
const { data } = await filterEmailLog({
page: pagination.page,
size: pagination.size,
search: filter?.search,
date: (filter as any)?.date,
});
const list = ((data?.data?.list || []) as API.MessageLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterGiftLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { OrderLink } from "@/components/order-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function GiftLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const getGiftTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.GiftLog, { search?: string }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.subscribe_id)}
/>
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "amount",
header: t("column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={row.original.amount} />
),
},
{
accessorKey: "balance",
header: t("column.balance", "Balance"),
cell: ({ row }) => (
<Display type="currency" value={row.original.balance} />
),
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getGiftTypeText(row.original.type)}</Badge>
),
},
{ accessorKey: "remark", header: t("column.remark", "Remark") },
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.gift", "Gift Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterGiftLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterLoginLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function LoginLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.LoginLog, { date?: string; user_id?: number }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => (
<div>
<Badge className="capitalize">{row.original.method}</Badge>{" "}
<UserDetail id={Number(row.original.user_id)} />
</div>
),
},
{
accessorKey: "login_ip",
header: t("column.ip", "IP"),
cell: ({ row }) => (
<IpLink ip={String((row.original as any).login_ip || "")} />
),
},
{
accessorKey: "user_agent",
header: t("column.userAgent", "User Agent"),
cell: ({ row }) => {
const userAgent = String(row.original.user_agent || "");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-48 cursor-help truncate">
{userAgent}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="wrap-break-word max-w-md">{userAgent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
accessorKey: "success",
header: t("column.success", "Success"),
cell: ({ row }) => (
<Badge variant={row.original.success ? "default" : "destructive"}>
{row.original.success
? t("success", "Success")
: t("failed", "Failed")}
</Badge>
),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.login", "Login Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterLoginLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = ((data?.data?.list || []) as API.LoginLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterMobileLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
export default function MobileLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
search: sp.search || undefined,
date: sp.date || today,
};
return (
<ProTable<API.MessageLog, { search?: string }>
columns={[
{
accessorKey: "platform",
header: t("column.platform", "Platform"),
cell: ({ row }) => <Badge>{row.getValue("platform")}</Badge>,
},
{ accessorKey: "to", header: t("column.to", "To") },
{ accessorKey: "subject", header: t("column.subject", "Subject") },
{
accessorKey: "content",
header: t("column.content", "Content"),
cell: ({ row }) => (
<pre className="wrap-break-word max-w-[480px] overflow-auto whitespace-pre-wrap text-xs">
{JSON.stringify(row.original.content || {}, null, 2)}
</pre>
),
},
{
accessorKey: "status",
header: t("column.status", "Status"),
cell: ({ row }) => {
const status = row.original.status;
const getStatusVariant = (status: any) => {
if (status === 1) {
return "default";
}
if (status === 0) {
return "destructive";
}
return "outline";
};
const getStatusText = (status: any) => {
if (status === 1) return t("sent", "Sent");
if (status === 0) return t("failed", "Failed");
return t("unknown", "Unknown");
};
return (
<Badge variant={getStatusVariant(status)}>
{getStatusText(status)}
</Badge>
);
},
},
{
accessorKey: "created_at",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.created_at),
},
]}
header={{ title: t("title.mobile", "SMS Log") }}
initialFilters={initialFilters}
params={[{ key: "search" }, { key: "date", type: "date" }]}
request={async (pagination, filter) => {
const { data } = await filterMobileLog({
page: pagination.page,
size: pagination.size,
search: filter?.search,
date: (filter as any)?.date,
});
const list = ((data?.data?.list || []) as API.MessageLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterRegisterLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function RegisterLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
};
return (
<ProTable<API.RegisterLog, { date?: string; user_id?: number }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "auth_method",
header: t("column.identifier", "Identifier"),
cell: ({ row }) => (
<div className="flex items-center">
<Badge className="capitalize">{row.original.auth_method}</Badge>
<span className="ml-1 text-sm">{row.original.identifier}</span>
</div>
),
},
{
accessorKey: "register_ip",
header: t("column.ip", "IP"),
cell: ({ row }) => (
<IpLink ip={String((row.original as any).register_ip || "")} />
),
},
{
accessorKey: "user_agent",
header: t("column.userAgent", "User Agent"),
cell: ({ row }) => {
const userAgent = String(row.original.user_agent || "");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-48 cursor-help truncate">
{userAgent}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="wrap-break-word max-w-md">{userAgent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.register", "Register Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterRegisterLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterResetSubscribeLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { OrderLink } from "@/components/order-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function ResetSubscribeLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const getResetSubscribeTypeText = (type: number) => {
const typeText = t(`type.${type}`, { defaultValue: "" });
if (!typeText) {
return `${t("unknown", "Unknown")} (${type})`;
}
return typeText;
};
const initialFilters = {
date: sp.date || today,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
};
return (
<ProTable<
API.ResetSubscribeLog,
{ date?: string; user_subscribe_id?: number }
>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "user_subscribe_id",
header: t("column.subscribeId", "Subscribe ID"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.user_subscribe_id)}
/>
),
},
{
accessorKey: "type",
header: t("column.type", "Type"),
cell: ({ row }) => (
<Badge>{getResetSubscribeTypeText(row.original.type)}</Badge>
),
},
{
accessorKey: "order_no",
header: t("column.orderNo", "Order No."),
cell: ({ row }) => <OrderLink orderId={row.original.order_no} />,
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.resetSubscribe", "Reset Subscribe Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{
key: "user_subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterResetSubscribeLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_subscribe_id: (filter as any)?.user_subscribe_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,84 @@
"use client";
import { Link, useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterServerTrafficLog } from "@workspace/ui/services/admin/log";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { useServer } from "@/stores/server";
export default function ServerTrafficLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const { getServerName } = useServer();
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
server_id: sp.server_id ? Number(sp.server_id) : undefined,
};
return (
<ProTable<API.ServerTrafficLog, { date?: string; server_id?: number }>
actions={{
render: (row) => [
<Button asChild key="detail">
<Link
search={{ date: row.date, server_id: row.server_id }}
to="/dashboard/log/traffic-details"
>
{t("detail", "Detail")}
</Link>
</Button>,
],
}}
columns={[
{
accessorKey: "server_id",
header: t("column.server", "Server"),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge>{row.original.server_id}</Badge>
<span>{getServerName(row.original.server_id)}</span>
</div>
),
},
{
accessorKey: "upload",
header: t("column.upload", "Upload"),
cell: ({ row }) => formatBytes(row.original.upload),
},
{
accessorKey: "download",
header: t("column.download", "Download"),
cell: ({ row }) => formatBytes(row.original.download),
},
{
accessorKey: "total",
header: t("column.total", "Total"),
cell: ({ row }) => formatBytes(row.original.total),
},
{ accessorKey: "date", header: t("column.date", "Date") },
]}
header={{ title: t("title.serverTraffic", "Server Traffic Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "server_id", placeholder: t("column.serverId", "Server ID") },
]}
request={async (pagination, filter) => {
const { data } = await filterServerTrafficLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
server_id: (filter as any)?.server_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,107 @@
"use client";
import { Link, useSearch } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterUserSubscribeTrafficLog } from "@workspace/ui/services/admin/log";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
export default function SubscribeTrafficLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
};
return (
<ProTable<
API.UserSubscribeTrafficLog,
{ date?: string; user_id?: number; user_subscribe_id?: number }
>
actions={{
render: (row) => [
<Button asChild key="detail">
<Link
search={{
date: row.date,
user_id: row.user_id,
subscribe_id: row.subscribe_id,
}}
to="/dashboard/log/traffic-details"
>
{t("detail", "Detail")}
</Link>
</Button>,
],
}}
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.subscribe_id)}
/>
),
},
{
accessorKey: "upload",
header: t("column.upload", "Upload"),
cell: ({ row }) => formatBytes(row.original.upload),
},
{
accessorKey: "download",
header: t("column.download", "Download"),
cell: ({ row }) => formatBytes(row.original.download),
},
{
accessorKey: "total",
header: t("column.total", "Total"),
cell: ({ row }) => formatBytes(row.original.total),
},
{
accessorKey: "date",
header: t("column.date", "Date"),
},
]}
header={{ title: t("title.subscribeTraffic", "Subscribe Traffic Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
{
key: "user_subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterUserSubscribeTrafficLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
});
const list =
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,107 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@workspace/ui/components/tooltip";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterSubscribeLog } from "@workspace/ui/services/admin/log";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
export default function SubscribeLogPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
user_subscribe_id: sp.user_subscribe_id
? Number(sp.user_subscribe_id)
: undefined,
};
return (
<ProTable<API.SubscribeLog, { date?: string; user_id?: number }>
columns={[
{
accessorKey: "user",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "user_subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.user_subscribe_id)}
/>
),
},
{
accessorKey: "client_ip",
header: t("column.ip", "IP"),
cell: ({ row }) => (
<IpLink ip={String((row.original as any).client_ip || "")} />
),
},
{
accessorKey: "user_agent",
header: t("column.userAgent", "User Agent"),
cell: ({ row }) => {
const userAgent = String(row.original.user_agent || "");
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-48 cursor-help truncate">
{userAgent}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="wrap-break-word max-w-md">{userAgent}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.subscribe", "Subscribe Log") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
{
key: "user_subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterSubscribeLog({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
user_id: (filter as any)?.user_id,
user_subscribe_id: (filter as any)?.user_subscribe_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useSearch } from "@tanstack/react-router";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { filterTrafficLogDetails } from "@workspace/ui/services/admin/log";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { useTranslation } from "react-i18next";
import { UserDetail, UserSubscribeDetail } from "@/sections/user/user-detail";
import { useServer } from "@/stores/server";
import { formatDate } from "@/utils/common";
export default function TrafficDetailsPage() {
const { t } = useTranslation("log");
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
const { getServerName } = useServer();
const today = new Date().toISOString().split("T")[0];
const initialFilters = {
date: sp.date || today,
server_id: sp.server_id ? Number(sp.server_id) : undefined,
user_id: sp.user_id ? Number(sp.user_id) : undefined,
subscribe_id: sp.subscribe_id ? Number(sp.subscribe_id) : undefined,
};
return (
<ProTable<API.TrafficLogDetails, { search?: string }>
columns={[
{
accessorKey: "server_id",
header: t("column.server", "Server"),
cell: ({ row }) => (
<span>
{getServerName(row.original.server_id)} ({row.original.server_id})
</span>
),
},
{
accessorKey: "user_id",
header: t("column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "subscribe_id",
header: t("column.subscribe", "Subscribe"),
cell: ({ row }) => (
<UserSubscribeDetail
enabled
hoverCard
id={Number(row.original.subscribe_id)}
/>
),
},
{
accessorKey: "upload",
header: t("column.upload", "Upload"),
cell: ({ row }) => formatBytes(row.original.upload),
},
{
accessorKey: "download",
header: t("column.download", "Download"),
cell: ({ row }) => formatBytes(row.original.download),
},
{
accessorKey: "timestamp",
header: t("column.time", "Time"),
cell: ({ row }) => formatDate(row.original.timestamp),
},
]}
header={{ title: t("title.trafficDetails", "Traffic Details") }}
initialFilters={initialFilters}
params={[
{ key: "date", type: "date" },
{ key: "server_id", placeholder: t("column.serverId", "Server ID") },
{ key: "user_id", placeholder: t("column.userId", "User ID") },
{
key: "subscribe_id",
placeholder: t("column.subscribeId", "Subscribe ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await filterTrafficLogDetails({
page: pagination.page,
size: pagination.size,
date: (filter as any)?.date,
server_id: (filter as any)?.server_id,
user_id: (filter as any)?.user_id,
subscribe_id: (filter as any)?.subscribe_id,
});
const list = (data?.data?.list || []) as any[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
@@ -0,0 +1,670 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Textarea } from "@workspace/ui/components/textarea";
import { HTMLEditor } from "@workspace/ui/composed/editor/html";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
createBatchSendEmailTask,
getPreSendEmailCount,
} from "@workspace/ui/services/admin/marketing";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
export default function EmailBroadcastForm() {
const { t } = useTranslation("marketing");
// Define schema with internationalized error messages
const emailBroadcastSchema = z.object({
subject: z
.string()
.min(
1,
`${t("subject", "Email Subject")} ${t("cannotBeEmpty", "cannot be empty")}`
),
content: z
.string()
.min(
1,
`${t("content", "Email Content")} ${t("cannotBeEmpty", "cannot be empty")}`
),
scope: z.number(),
register_start_time: z.string().optional(),
register_end_time: z.string().optional(),
additional: z
.string()
.optional()
.refine(
(value) => {
if (!value || value.trim() === "") return true;
const emails = value
.split("\n")
.filter((email) => email.trim() !== "");
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emails.every((email) => emailRegex.test(email.trim()));
},
{
message: t(
"pleaseEnterValidEmailAddresses",
"Please enter valid email addresses, one per line"
),
}
),
scheduled: z.string().optional(),
interval: z
.number()
.min(
0.1,
t("emailIntervalMinimum", "Email interval must be at least 0.1 seconds")
)
.optional(),
limit: z
.number()
.min(1, t("dailyLimit", "Daily limit must be at least 1"))
.optional(),
});
type EmailBroadcastFormData = z.infer<typeof emailBroadcastSchema>;
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [estimatedRecipients, setEstimatedRecipients] = useState<{
users: number;
additional: number;
total: number;
}>({ users: 0, additional: 0, total: 0 });
const form = useForm<EmailBroadcastFormData>({
resolver: zodResolver(emailBroadcastSchema),
defaultValues: {
subject: "",
content: "",
scope: 1, // ScopeAll
register_start_time: "",
register_end_time: "",
additional: "",
scheduled: "",
interval: 1,
limit: 1000,
},
});
// Calculate recipient count
const calculateRecipients = async () => {
const formData = form.getValues();
try {
// Call API to get actual recipient count
const scope = formData.scope || 1; // Default to ScopeAll
// Convert dates to timestamps if they exist
let register_start_time = 0;
let register_end_time = 0;
if (formData.register_start_time) {
register_start_time = Math.floor(
new Date(formData.register_start_time).getTime()
);
}
if (formData.register_end_time) {
register_end_time = Math.floor(
new Date(formData.register_end_time).getTime()
);
}
const response = await getPreSendEmailCount({
scope,
register_start_time,
register_end_time,
});
const userCount = response.data?.data?.count || 0;
// Calculate additional email count
const additionalEmails = formData.additional || "";
const additionalCount = additionalEmails
.split("\n")
.filter((email: string) => email.trim() !== "").length;
const total = userCount + additionalCount;
setEstimatedRecipients({
users: userCount,
additional: additionalCount,
total,
});
} catch (error) {
console.error("Failed to get recipient count:", error);
// Set to 0 if API fails, don't use fallback simulation
const additionalEmails = formData.additional || "";
const additionalCount = additionalEmails
.split("\n")
.filter((email: string) => email.trim() !== "").length;
setEstimatedRecipients({
users: 0,
additional: additionalCount,
total: additionalCount,
});
}
};
// Listen to form changes
const watchedValues = form.watch();
// Use useEffect to respond to form changes, but only when sheet is open
useEffect(() => {
if (!open) return; // Only calculate when sheet is open
const debounceTimer = setTimeout(() => {
calculateRecipients();
}, 500); // Add debounce to avoid too frequent API calls
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open, // Add open dependency
watchedValues.scope,
watchedValues.register_start_time,
watchedValues.register_end_time,
watchedValues.additional,
]);
const onSubmit = async (data: EmailBroadcastFormData) => {
setLoading(true);
try {
// Validate scheduled send time
let scheduled: number | undefined;
if (data.scheduled && data.scheduled.trim() !== "") {
const scheduledDate = new Date(data.scheduled);
const now = new Date();
if (scheduledDate <= now) {
toast.error(
t(
"scheduledSendTimeMustBeLater",
"Scheduled send time must be later than current time"
)
);
return;
}
scheduled = Math.floor(scheduledDate.getTime());
}
let register_start_time = 0;
let register_end_time = 0;
if (data.register_start_time) {
register_start_time = Math.floor(
new Date(data.register_start_time).getTime()
);
}
if (data.register_end_time) {
register_end_time = Math.floor(
new Date(data.register_end_time).getTime()
);
}
// Prepare API request data
const requestData: API.CreateBatchSendEmailTaskRequest = {
subject: data.subject,
content: data.content,
scope: data.scope,
register_start_time,
register_end_time,
additional: data.additional || undefined,
scheduled,
interval: data.interval ? data.interval * 1000 : undefined, // Convert seconds to milliseconds
limit: data.limit,
};
// Call API to create batch send email task
await createBatchSendEmailTask(requestData);
if (!data.scheduled || data.scheduled.trim() === "") {
toast.success(
t(
"emailBroadcastTaskCreatedSuccessfully",
"Email broadcast task created successfully"
)
);
} else {
toast.success(
t("emailAddedToScheduledQueue", "Email added to scheduled send queue")
);
}
form.reset();
setOpen(false);
} catch (error) {
console.error("Email broadcast failed:", error);
toast.error(t("sendFailed", "Send failed, please try again"));
} finally {
setLoading(false);
}
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:email-send" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("emailBroadcast", "Email Broadcast")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"createNewEmailBroadcastCampaign",
"Create new email broadcast campaign"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[700px] max-w-full md:max-w-screen-lg">
<SheetHeader>
<SheetTitle>{t("createBroadcast", "Create Broadcast")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="space-y-2 pt-4"
id="broadcast-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<Tabs className="space-y-2" defaultValue="content">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="content">
{t("content", "Email Content")}
</TabsTrigger>
<TabsTrigger value="settings">
{t("sendSettings", "Send Settings")}
</TabsTrigger>
</TabsList>
{/* Email Content Tab */}
<TabsContent className="space-y-2" value="content">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel>{t("subject", "Email Subject")}</FormLabel>
<FormControl>
<Input
placeholder={`${t("pleaseEnter", "Please enter")} ${t("subject", "Email Subject").toLowerCase()}`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{t("content", "Email Content")}</FormLabel>
<FormControl>
<HTMLEditor
onChange={(value) => {
form.setValue(field.name, value || "");
}}
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"useMarkdownEditor",
"Use Markdown editor to write email content with preview functionality"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
{/* Send Settings Tab */}
<TabsContent className="space-y-2" value="settings">
{/* Send scope and estimated recipients */}
<div className="grid grid-cols-2 items-center gap-4">
<FormField
control={form.control}
name="scope"
render={({ field }) => (
<FormItem>
<FormLabel>{t("sendScope", "Send Scope")}</FormLabel>
<Select
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
value={field.value?.toString() || "1"}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectSendScope",
"Select send scope"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="1">
{t("allUsers", "All Users")}
</SelectItem>{" "}
{/* ScopeAll */}
<SelectItem value="2">
{t(
"subscribedUsersOnly",
"Subscribed users only"
)}
</SelectItem>{" "}
{/* ScopeActive */}
<SelectItem value="3">
{t(
"expiredSubscriptionUsersOnly",
"Expired subscription users only"
)}
</SelectItem>{" "}
{/* ScopeExpired */}
<SelectItem value="4">
{t(
"noSubscriptionUsersOnly",
"No subscription users only"
)}
</SelectItem>{" "}
{/* ScopeNone */}
<SelectItem value="5">
{t(
"specificUsersOnly",
"Additional emails only (skip platform users)"
)}
</SelectItem>{" "}
{/* ScopeSkip */}
</SelectContent>
</Select>
<FormDescription>
{t(
"sendScopeDescription",
'Choose the user scope for email sending. Select "Additional emails only" to send only to the email addresses filled below'
)}
</FormDescription>
</FormItem>
)}
/>
{/* Estimated recipients info */}
<div className="flex justify-end">
<div className="border-l-4 border-l-primary bg-primary/10 px-4 py-3 text-sm">
<span className="text-muted-foreground">
{t("estimatedRecipients", "Estimated recipients")}:{" "}
</span>
<span className="font-medium text-lg text-primary">
{estimatedRecipients.total}
</span>
<span className="ml-2 text-muted-foreground text-xs">
({t("users", "users")}: {estimatedRecipients.users},{" "}
{t("additional", "Additional")}:{" "}
{estimatedRecipients.additional})
</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="register_start_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"registrationStartDate",
"Registration Start Date"
)}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={form.watch("scope") === 5}
onValueChange={field.onChange}
step="1" // ScopeSkip
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeUsersRegisteredAfter",
"Include users registered on or after this date"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="register_end_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("registrationEndDate", "Registration End Date")}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={form.watch("scope") === 5}
onValueChange={field.onChange}
step="1" // ScopeSkip
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeUsersRegisteredBefore",
"Include users registered on or before this date"
)}
</FormDescription>
</FormItem>
)}
/>
</div>
{/* Additional recipients */}
<FormField
control={form.control}
name="additional"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"additionalRecipientEmails",
"Additional recipient emails"
)}
</FormLabel>
<FormControl>
<Textarea
className="min-h-[120px] font-mono text-sm"
placeholder={`${t("pleaseEnter", "Please enter")}${t("additionalRecipientEmails", "Additional recipient emails").toLowerCase()}${t("onePerLine", "one per line")}for example:\nexample1@domain.com\nexample2@domain.com\nexample3@domain.com`}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"additionalRecipientsDescription",
"These emails will receive the broadcast in addition to the user filter above"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send time settings */}
<FormField
control={form.control}
name="scheduled"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("scheduledSend", "Schedule Send")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"leaveEmptyForImmediateSend",
"Leave empty for immediate send"
)}
step="1"
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"selectSendTime",
"Select send time, leave empty for immediate send"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Send rate control */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="interval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("emailInterval", "Email Interval (seconds)")}
</FormLabel>
<FormControl>
<Input
min={1}
placeholder="1"
step={0.1}
type="number"
{...field}
onChange={(e) =>
field.onChange(
Number.parseFloat(e.target.value) || 1
)
}
/>
</FormControl>
<FormDescription>
{t(
"intervalTimeBetweenEmails",
"Interval time between each email"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="limit"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("dailySendLimit", "Daily Send Limit")}
</FormLabel>
<FormControl>
<Input
min={1}
placeholder="1000"
step={1}
type="number"
{...field}
onChange={(e) =>
field.onChange(
Number.parseInt(e.target.value, 10) || 1000
)
}
/>
</FormControl>
<FormDescription>
{t(
"maximumNumberPerDay",
"Maximum number of emails to send per day"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex flex-row items-center justify-end gap-2 pt-3">
<Button onClick={() => setOpen(false)} variant="outline">
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} form="broadcast-form" type="submit">
{loading && (
<Icon className="mr-2 h-4 w-4 animate-spin" icon="mdi:loading" />
)}
{loading
? t("processing", "Processing...")
: !form.watch("scheduled") ||
form.watch("scheduled")?.trim() === ""
? t("sendNow", "Send Now")
: t("scheduleSend", "Schedule Send")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,308 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Icon } from "@workspace/ui/composed/icon";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
getBatchSendEmailTaskList,
stopBatchSendEmailTask,
} from "@workspace/ui/services/admin/marketing";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
export default function EmailTaskManager() {
const { t } = useTranslation("marketing");
const ref = useRef<ProTableActions>(null);
const [selectedTask, setSelectedTask] =
useState<API.BatchSendEmailTask | null>(null);
const [open, setOpen] = useState(false);
const stopTask = async (taskId: number) => {
try {
await stopBatchSendEmailTask({
id: taskId,
});
toast.success(t("taskStoppedSuccessfully", "Task stopped successfully"));
ref.current?.refresh();
} catch (error) {
console.error("Failed to stop task:", error);
toast.error(t("failedToStopTask", "Failed to stop task"));
}
};
const getStatusBadge = (status: number) => {
const statusConfig = {
0: {
label: t("notStarted", "Not Started"),
variant: "secondary" as const,
},
1: { label: t("inProgress", "In Progress"), variant: "default" as const },
2: { label: t("completed", "Completed"), variant: "default" as const },
};
const config = statusConfig[status as keyof typeof statusConfig] || {
label: `${t("status", "Status")} ${status}`,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon
className="h-5 w-5 text-primary"
icon="mdi:email-multiple"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("emailTaskManager", "Email Task Manager")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"viewAndManageEmailBroadcastTasks",
"View and manage email broadcast tasks"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[1000px] max-w-full md:max-w-screen-lg">
<SheetHeader>
<SheetTitle>
{t("emailBroadcastTasks", "Email Broadcast Tasks")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6">
<div className="mt-4 space-y-4">
<ProTable<
API.BatchSendEmailTask,
API.GetBatchSendEmailTaskListParams
>
action={ref}
actions={{
render: (row) => [
<Dialog key="view-content">
<DialogTrigger asChild>
<Button
onClick={() =>
setSelectedTask(row as API.BatchSendEmailTask)
}
size="icon"
variant="outline"
>
<Icon icon="mdi:eye" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] max-w-4xl">
<DialogHeader>
<DialogTitle>
{t("emailContent", "Email Content")}
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[60vh] pr-4">
{selectedTask && (
<div className="space-y-4">
<div>
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
{t("subject", "Email Subject")}
</h4>
<p className="font-medium">
{selectedTask.subject}
</p>
</div>
<div>
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
{t("content", "Email Content")}
</h4>
<div
dangerouslySetInnerHTML={{
__html: selectedTask.content,
}}
/>
</div>
{selectedTask.additional && (
<div>
<h4 className="mb-2 font-medium text-muted-foreground text-sm">
{t(
"additionalRecipients",
"Additional Recipients"
)}
</h4>
<p className="text-sm">
{selectedTask.additional}
</p>
</div>
)}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>,
...([0, 1].includes(row.status)
? [
<Button
key="stop"
onClick={() => stopTask(row.id)}
variant="destructive"
>
{t("stop", "Stop")}
</Button>,
]
: []),
],
}}
columns={[
{
accessorKey: "subject",
header: t("subject", "Email Subject"),
cell: ({ row }) => (
<div
className="max-w-[200px] truncate font-medium"
title={row.getValue("subject") as string}
>
{row.getValue("subject") as string}
</div>
),
},
{
accessorKey: "scope",
header: t("recipientType", "Recipient Type"),
cell: ({ row }) => {
const scope = row.original.scope;
const scopeLabels = {
1: t("allUsers", "All Users"), // ScopeAll
2: t("subscribedUsers", "Subscribed Users"), // ScopeActive
3: t("expiredUsers", "Expired Users"), // ScopeExpired
4: t("nonSubscribers", "Non-subscribers"), // ScopeNone
5: t("specificUsers", "Specific Users"), // ScopeSkip
};
return (
scopeLabels[scope as keyof typeof scopeLabels] ||
`${t("scope", "Send Scope")} ${scope}`
);
},
},
{
accessorKey: "status",
header: t("status", "Status"),
cell: ({ row }) =>
getStatusBadge(row.getValue("status") as number),
},
{
accessorKey: "progress",
header: t("progress", "Progress"),
cell: ({ row }) => {
const task = row.original as API.BatchSendEmailTask;
const progress =
task.total > 0 ? (task.current / task.total) * 100 : 0;
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>
{task.current} / {task.total}
</span>
<span>{progress.toFixed(1)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
},
},
{
accessorKey: "scheduled",
header: t("sendTime", "Send Time"),
cell: ({ row }) => {
const scheduled = row.getValue("scheduled") as number;
return scheduled && scheduled > 0
? formatDate(scheduled)
: "--";
},
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
cell: ({ row }) => {
const createdAt = row.getValue("created_at") as number;
return formatDate(createdAt);
},
},
]}
params={[
{
key: "status",
placeholder: t("status", "Status"),
options: [
{ label: t("notStarted", "Not Started"), value: "0" },
{ label: t("inProgress", "In Progress"), value: "1" },
{ label: t("completed", "Completed"), value: "2" },
],
},
{
key: "scope",
placeholder: t("sendScope", "Send Scope"),
options: [
{ label: t("allUsers", "All Users"), value: "1" },
{
label: t("subscribedUsers", "Subscribed Users"),
value: "2",
},
{ label: t("expiredUsers", "Expired Users"), value: "3" },
{
label: t("nonSubscribers", "Non-subscribers"),
value: "4",
},
{ label: t("specificUsers", "Specific Users"), value: "5" },
],
},
]}
request={async (pagination, filters) => {
const response = await getBatchSendEmailTaskList({
...filters,
page: pagination.page,
size: pagination.size,
});
return {
list: response.data?.data?.list || [],
total: response.data?.data?.total || 0,
};
}}
/>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,56 @@
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@workspace/ui/components/table";
import { useTranslation } from "react-i18next";
import EmailBroadcastForm from "./email/broadcast-form";
import EmailTaskManager from "./email/task-manager";
import QuotaBroadcastForm from "./quota/broadcast-form";
import QuotaTaskManager from "./quota/task-manager";
export default function MarketingPage() {
const { t } = useTranslation("marketing");
const formSections = [
{
title: t("emailMarketing", "Email Marketing"),
forms: [
{ component: EmailBroadcastForm },
{ component: EmailTaskManager },
],
},
{
title: t("quotaService", "Quota Service"),
forms: [
{ component: QuotaBroadcastForm },
{ component: QuotaTaskManager },
],
},
];
return (
<div className="space-y-8">
{formSections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<h2 className="mb-4 font-semibold text-lg">{section.title}</h2>
<Table>
<TableBody>
{section.forms.map((form, formIndex) => {
const FormComponent = form.component;
return (
<TableRow key={formIndex}>
<TableCell>
<FormComponent />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
))}
</div>
);
}
@@ -0,0 +1,534 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
createQuotaTask,
queryQuotaTaskPreCount,
} from "@workspace/ui/services/admin/marketing";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
export default function QuotaBroadcastForm() {
const { t } = useTranslation("marketing");
// Define schema with internationalized error messages
const quotaBroadcastSchema = z.object({
subscribers: z
.array(z.number())
.min(1, t("pleaseSelectSubscribers", "Please select packages")),
is_active: z.boolean(),
start_time: z.string().optional(),
end_time: z.string().optional(),
reset_traffic: z.boolean(),
days: z.number().optional(),
gift_type: z.number(),
gift_value: z.number().optional(),
});
type QuotaBroadcastFormData = z.infer<typeof quotaBroadcastSchema>;
const form = useForm<QuotaBroadcastFormData>({
resolver: zodResolver(quotaBroadcastSchema),
mode: "onChange", // Enable real-time validation
defaultValues: {
subscribers: [],
is_active: true,
start_time: "",
end_time: "",
reset_traffic: false,
days: 0,
gift_type: 1,
gift_value: 0,
},
});
const [recipients, setRecipients] = useState<number>(0);
const [isCalculating, setIsCalculating] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const { subscribes } = useSubscribe();
// Calculate recipient count
const calculateRecipients = async () => {
setIsCalculating(true);
try {
const formData = form.getValues();
let start_time = 0;
let end_time = 0;
if (formData.start_time) {
start_time = new Date(formData.start_time).getTime();
}
if (formData.end_time) {
end_time = new Date(formData.end_time).getTime();
}
const response = await queryQuotaTaskPreCount({
subscribers: formData.subscribers,
is_active: formData.is_active,
start_time,
end_time,
});
if (response.data?.data?.count !== undefined) {
setRecipients(response.data.data.count);
}
} catch (error) {
console.error("Failed to calculate recipients:", error);
toast.error(
t("failedToCalculateRecipients", "Failed to calculate recipients")
);
setRecipients(0);
} finally {
setIsCalculating(false);
}
};
// Watch form values and recalculate recipients only when sheet is open
const watchedValues = form.watch();
useEffect(() => {
if (!open) return; // Only calculate when sheet is open
const debounceTimer = setTimeout(() => {
calculateRecipients();
}, 500); // Add debounce to avoid too frequent API calls
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open,
watchedValues.subscribers,
watchedValues.is_active,
watchedValues.start_time,
watchedValues.end_time,
]);
const onSubmit = async (data: QuotaBroadcastFormData) => {
setIsSubmitting(true);
try {
let start_time = 0;
let end_time = 0;
if (data.start_time) {
start_time = Math.floor(new Date(data.start_time).getTime());
}
if (data.end_time) {
end_time = Math.floor(new Date(data.end_time).getTime());
}
await createQuotaTask({
subscribers: data.subscribers,
is_active: data.is_active,
start_time,
end_time,
reset_traffic: data.reset_traffic,
days: data.days || 0,
gift_type: data.gift_type,
gift_value: data.gift_value || 0,
});
toast.success(
t("quotaTaskCreatedSuccessfully", "Quota task created successfully")
);
form.reset();
setRecipients(0);
setOpen(false); // Close the sheet after successful submission
} catch (error) {
console.error("Failed to create quota task:", error);
toast.error(t("failedToCreateQuotaTask", "Failed to create quota task"));
} finally {
setIsSubmitting(false);
}
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:gift" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("quotaBroadcast", "Quota Distribution")}
</p>
<p className="text-muted-foreground text-sm">
{t(
"createAndSendQuotaTasks",
"Create and Distribute Quota Tasks"
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{t("createQuotaTask", "Create Quota Task")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-32px-env(safe-area-inset-top))] px-6">
<Form {...form}>
<form
className="mt-4 space-y-6"
id="quota-broadcast-form"
onSubmit={form.handleSubmit(onSubmit)}
>
{/* Subscribers selection */}
<FormField
control={form.control}
name="subscribers"
render={({ field }) => (
<FormItem>
<FormLabel>{t("subscribers", "Packages")}</FormLabel>
<FormControl>
<Combobox
multiple={true}
onChange={field.onChange}
options={subscribes?.map((subscribe) => ({
value: subscribe.id!,
label: subscribe.name!,
children: (
<div>
<div>{subscribe.name}</div>
<div className="text-muted-foreground text-xs">
<Display
type="traffic"
value={subscribe.traffic || 0}
/>{" "}
/{" "}
<Display
type="currency"
value={subscribe.unit_price || 0}
/>
</div>
</div>
),
}))}
placeholder={t(
"pleaseSelectSubscribers",
"Please select packages"
)}
value={field.value || []}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Subscription count info and active status */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="is_active"
render={({ field }) => (
<FormItem>
<FormLabel>{t("validOnly", "Valid Only")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"selectValidSubscriptionsOnly",
"Select currently valid subscriptions only"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center border-l-4 border-l-primary bg-primary/10 px-4 py-3 text-sm">
<span className="text-muted-foreground">
{t("subscriptionCount", "Subscription Count")}:{" "}
</span>
<span className="font-medium text-lg text-primary">
{isCalculating ? (
<Icon
className="ml-2 h-4 w-4 animate-spin"
icon="mdi:loading"
/>
) : (
recipients.toLocaleString()
)}
</span>
</div>
</div>
{/* Subscription validity period range */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="start_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"subscriptionValidityStartDate",
"Subscription Validity Start Date"
)}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
step="1"
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeSubscriptionsValidAfter",
"Include subscriptions valid on or after this date"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="end_time"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"subscriptionValidityEndDate",
"Subscription Validity End Date"
)}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
step="1"
type="datetime-local"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"includeSubscriptionsValidBefore",
"Include subscriptions valid on or before this date"
)}
</FormDescription>
</FormItem>
)}
/>
</div>
{/* Reset traffic */}
<FormField
control={form.control}
name="reset_traffic"
render={({ field }) => (
<FormItem>
<FormLabel>{t("resetTraffic", "Reset Traffic")}</FormLabel>
<FormControl>
<Switch
checked={field.value}
className="!mt-0 float-end"
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t(
"resetTrafficDescription",
"Whether to reset subscription used traffic"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Quota days */}
<FormField
control={form.control}
name="days"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("quotaDays", "Extend Expiration Days")}
</FormLabel>
<FormControl>
<EnhancedInput
min={1}
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
type="number"
value={field.value?.toString()}
/>
</FormControl>
<FormDescription>
{t(
"numberOfDaysForTheQuota",
"Number of days to extend subscription expiration"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Gift configuration */}
<FormField
control={form.control}
name="gift_type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftType", "Gift Amount Type")}</FormLabel>
<FormControl>
<RadioGroup
className="flex gap-4"
defaultValue={String(field.value)}
onValueChange={(value) => {
field.onChange(Number(value));
form.setValue("gift_value", 0);
}}
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="1" />
</FormControl>
<FormLabel className="font-normal">
{t("fixedAmount", "Fixed Amount")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="2" />
</FormControl>
<FormLabel className="font-normal">
{t("percentageAmount", "Percentage Amount")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Gift amount based on type */}
{form.watch("gift_type") === 1 && (
<FormField
control={form.control}
name="gift_value"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
<FormControl>
<EnhancedInput<number>
formatInput={(value) =>
unitConversion("centsToDollars", value)
}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={1}
onValueChange={(value) => field.onChange(value)}
placeholder={t("enterAmount", "Enter amount")}
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("gift_type") === 2 && (
<FormField
control={form.control}
name="gift_value"
render={({ field }) => (
<FormItem>
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
<FormControl>
<EnhancedInput
max={100}
min={1}
onValueChange={(value) => field.onChange(value)}
placeholder={t("enterPercentage", "Enter percentage")}
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormDescription>
{t(
"percentageAmountDescription",
"Gift percentage amount based on current package price"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex flex-row items-center justify-end gap-2 pt-3">
<Button onClick={() => setOpen(false)} variant="outline">
{t("cancel", "Cancel")}
</Button>
<Button
disabled={
isSubmitting ||
!form.formState.isValid ||
form.watch("subscribers").length === 0
}
form="quota-broadcast-form"
type="submit"
>
{isSubmitting && (
<Icon className="mr-2 h-4 w-4 animate-spin" icon="mdi:loading" />
)}
{t("createQuotaTask", "Create Quota Task")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,256 @@
import { Badge } from "@workspace/ui/components/badge";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Icon } from "@workspace/ui/composed/icon";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { queryQuotaTaskList } from "@workspace/ui/services/admin/marketing";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
export default function QuotaTaskManager() {
const { t } = useTranslation("marketing");
const [open, setOpen] = useState(false);
const { subscribes } = useSubscribe();
const subscribeMap =
subscribes?.reduce(
(acc, subscribe) => {
acc[subscribe.id!] = subscribe.name!;
return acc;
},
{} as Record<number, string>
) || {};
const getStatusBadge = (status: number) => {
const statusConfig = {
0: {
label: t("notStarted", "Not Started"),
variant: "secondary" as const,
},
1: { label: t("inProgress", "In Progress"), variant: "default" as const },
2: { label: t("completed", "Completed"), variant: "default" as const },
};
const config = statusConfig[status as keyof typeof statusConfig] || {
label: `${t("status", "Status")} ${status}`,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<div className="flex cursor-pointer items-center justify-between transition-colors">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" icon="mdi:database-plus" />
</div>
<div className="flex-1">
<p className="font-medium">
{t("quotaTaskManager", "Quota Task Manager")}
</p>
<p className="text-muted-foreground text-sm">
{t("viewAndManageQuotaTasks", "View and manage quota tasks")}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</SheetTrigger>
<SheetContent className="w-[1000px] max-w-full md:max-w-screen-lg">
<SheetHeader>
<SheetTitle>{t("quotaTasks", "Quota Tasks")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6">
<div className="mt-4 space-y-4">
{open && (
<ProTable<API.QuotaTask, API.QueryQuotaTaskListParams>
columns={[
{
accessorKey: "subscribers",
header: t("subscribers", "Packages"),
size: 200,
cell: ({ row }) => {
const subscribers = row.getValue(
"subscribers"
) as number[];
const subscriptionNames =
subscribers
?.map((id) => subscribeMap[id])
.filter(Boolean) || [];
if (subscriptionNames.length === 0) {
return (
<span className="text-muted-foreground text-sm">
{t("noSubscriptions", "No Subscriptions")}
</span>
);
}
return (
<div className="flex flex-wrap gap-1">
{subscriptionNames.map((name, index) => (
<span
className="rounded bg-muted px-2 py-1 text-xs"
key={index}
>
{name}
</span>
))}
</div>
);
},
},
{
accessorKey: "is_active",
header: t("validOnly", "Valid Only"),
size: 120,
cell: ({ row }) => {
const isActive = row.getValue("is_active") as boolean;
return (
<span className="text-sm">
{isActive ? t("yes", "Yes") : t("no", "No")}
</span>
);
},
},
{
accessorKey: "reset_traffic",
header: t("resetTraffic", "Reset Traffic"),
size: 120,
cell: ({ row }) => {
const resetTraffic = row.getValue(
"reset_traffic"
) as boolean;
return (
<span className="text-sm">
{resetTraffic ? t("yes", "Yes") : t("no", "No")}
</span>
);
},
},
{
accessorKey: "gift_value",
header: t("giftAmount", "Gift Amount"),
size: 120,
cell: ({ row }) => {
const giftValue = row.getValue("gift_value") as number;
const task = row.original as API.QuotaTask;
const giftType = task.gift_type;
return (
<div className="font-medium text-sm">
{giftType === 1 ? (
<Display type="currency" value={giftValue} />
) : (
`${giftValue}%`
)}
</div>
);
},
},
{
accessorKey: "days",
header: t("quotaDays", "Extend Expiration Days"),
size: 100,
cell: ({ row }) => {
const days = row.getValue("days") as number;
return (
<span className="font-medium">
{days} {t("days", "Days")}
</span>
);
},
},
{
accessorKey: "time_range",
header: t("timeRange", "Time Range"),
size: 180,
cell: ({ row }) => {
const task = row.original as API.QuotaTask;
const startTime = task.start_time;
const endTime = task.end_time;
if (!(startTime || endTime)) {
return (
<span className="text-muted-foreground text-sm">
{t("noTimeLimit", "No Time Limit")}
</span>
);
}
return (
<div className="space-y-1 text-xs">
{startTime && (
<div>
{t("startTime", "Start Time")}:{" "}
{formatDate(startTime)}
</div>
)}
{endTime && (
<div>
{t("endTime", "End Time")}: {formatDate(endTime)}
</div>
)}
</div>
);
},
},
{
accessorKey: "status",
header: t("status", "Status"),
size: 100,
cell: ({ row }) =>
getStatusBadge(row.getValue("status") as number),
},
{
accessorKey: "created_at",
header: t("createdAt", "Created At"),
size: 150,
cell: ({ row }) => {
const createdAt = row.getValue("created_at") as number;
return formatDate(createdAt);
},
},
]}
params={[
{
key: "status",
placeholder: t("status", "Status"),
options: [
{ label: t("notStarted", "Not Started"), value: "0" },
{ label: t("inProgress", "In Progress"), value: "1" },
{ label: t("completed", "Completed"), value: "2" },
],
},
]}
request={async (pagination, filters) => {
const response = await queryQuotaTaskList({
...filters,
page: pagination.page,
size: pagination.size,
});
return {
list: response.data?.data?.list || [],
total: response.data?.data?.total || 0,
};
}}
/>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createNode,
deleteNode,
filterNodeList,
resetSortWithNode,
toggleNodeStatus,
updateNode,
} from "@workspace/ui/services/admin/server";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
import NodeForm from "./node-form";
export default function Nodes() {
const { t } = useTranslation("nodes");
const ref = useRef<ProTableActions>(null);
const [loading, setLoading] = useState(false);
// Use our zustand store for server data
const { getServerName, getServerAddress, getProtocolPort } = useServer();
const { fetchNodes, fetchTags } = useNode();
return (
<ProTable<API.Node, { search: string }>
action={ref}
actions={{
render: (row) => [
<NodeForm
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
const body: API.UpdateNodeRequest = {
...row,
...values,
} as any;
await updateNode(body);
toast.success(t("updated", "Updated"));
ref.current?.refresh();
fetchNodes();
fetchTags();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerEditTitle", "Edit Node")}
trigger={t("edit", "Edit")}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteNode({ id: row.id } as any);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
title={t("confirmDeleteTitle", "Delete this node?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<Button
key="copy"
onClick={async () => {
const {
id: _id,
sort: _sort,
enabled: _enabled,
updated_at: _updated_at,
created_at: _created_at,
...rest
} = row as any;
await createNode({
...rest,
enabled: false,
});
toast.success(t("copied", "Copied"));
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
variant="outline"
>
{t("copy", "Copy")}
</Button>,
],
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await Promise.all(
rows.map((r) => deleteNode({ id: r.id } as any))
);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
title={t("confirmDeleteTitle", "Delete this node?")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
];
},
}}
columns={[
{
id: "enabled",
header: t("enabled", "Enabled"),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={async (v) => {
await toggleNodeStatus({ id: row.original.id, enable: v });
toast.success(
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
);
ref.current?.refresh();
fetchNodes();
fetchTags();
}}
/>
),
},
{ accessorKey: "name", header: t("name", "Name") },
{
id: "address_port",
header: `${t("address", "Address")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.address || "—"}:${row.original.port || "—"}`,
},
{
id: "server_id",
header: t("server", "Server"),
cell: ({ row }) =>
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
},
{
id: "protocol",
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
cell: ({ row }) =>
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
},
{
accessorKey: "tags",
header: t("tags", "Tags"),
cell: ({ row }) => (
<div className="flex flex-wrap gap-1">
{(row.original.tags || []).length === 0
? "—"
: row.original.tags.map((tg) => (
<Badge key={tg} variant="outline">
{tg}
</Badge>
))}
</div>
),
},
]}
header={{
title: t("pageTitle", "Nodes"),
toolbar: (
<NodeForm
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
const body: API.CreateNodeRequest = {
name: values.name,
server_id: Number(values.server_id!),
protocol: values.protocol,
address: values.address,
port: Number(values.port!),
tags: values.tags || [],
enabled: false,
};
await createNode(body);
toast.success(t("created", "Created"));
ref.current?.refresh();
fetchNodes();
fetchTags();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerCreateTitle", "Create Node")}
trigger={t("create", "Create")}
/>
),
}}
onSort={async (source, target, items) => {
const sourceIndex = items.findIndex(
(item) => String(item.id) === source
);
const targetIndex = items.findIndex(
(item) => String(item.id) === target
);
const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort = originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const changedItems = updatedItems.filter(
(item, index) => item.sort !== items[index]?.sort
);
if (changedItems.length > 0) {
resetSortWithNode({
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
})) as API.SortItem[],
});
toast.success(t("sorted_success", "Sorted successfully"));
}
return updatedItems;
}}
params={[{ key: "search" }]}
request={async (pagination, filter) => {
const { data } = await filterNodeList({
page: pagination.page,
size: pagination.size,
search: filter?.search || undefined,
});
const list = (data?.data?.list || []) as API.Node[];
const total = Number(data?.data?.total || list.length);
return { list, total };
}}
/>
);
}
+416
View File
@@ -0,0 +1,416 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Combobox } from "@workspace/ui/composed/combobox";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import TagInput from "@workspace/ui/composed/tag-input";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
export type ProtocolName =
| "shadowsocks"
| "vmess"
| "vless"
| "trojan"
| "hysteria"
| "tuic"
| "anytls"
| "naive"
| "http"
| "socks"
| "mieru";
const buildSchema = (t: TFunction) =>
z.object({
name: z
.string()
.trim()
.min(1, t("errors.nameRequired", "Please enter a name")),
server_id: z
.number({ message: t("errors.serverRequired", "Please select a server") })
.int()
.gt(0, t("errors.serverRequired", "Please select a server"))
.optional(),
protocol: z
.string()
.min(1, t("errors.protocolRequired", "Please select a protocol")),
address: z
.string()
.trim()
.min(1, t("errors.serverAddrRequired", "Please enter an entry address")),
port: z
.number({
message: t("errors.portRange", "Port must be between 1 and 65535"),
})
.int()
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
tags: z.array(z.string()),
});
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
export default function NodeForm(props: {
trigger: string;
title: string;
loading?: boolean;
initialValues?: Partial<NodeFormValues>;
onSubmit: (values: NodeFormValues) => Promise<boolean> | boolean;
}) {
const { trigger, title, loading, initialValues, onSubmit } = props;
const { t } = useTranslation("nodes");
const Scheme = useMemo(() => buildSchema(t), [t]);
const [open, setOpen] = useState(false);
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(
new Set()
);
const addAutoFilledField = (fieldName: string) => {
setAutoFilledFields((prev) => new Set(prev).add(fieldName));
};
const removeAutoFilledField = (fieldName: string) => {
setAutoFilledFields((prev) => {
const newSet = new Set(prev);
newSet.delete(fieldName);
return newSet;
});
};
const form = useForm<NodeFormValues>({
resolver: zodResolver(Scheme),
defaultValues: {
name: "",
server_id: undefined,
protocol: "",
address: "",
port: 0,
tags: [],
...initialValues,
},
});
const serverId = form.watch("server_id");
const { servers, getAvailableProtocols } = useServer();
const { tags } = useNode();
const existingTags: string[] = tags || [];
const availableProtocols = getAvailableProtocols(serverId);
useEffect(() => {
if (initialValues) {
form.reset({
name: "",
server_id: undefined,
protocol: "",
address: "",
port: 0,
tags: [],
...initialValues,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
function handleServerChange(nextId?: number | null) {
const id = nextId ?? undefined;
form.setValue("server_id", id);
if (!id) {
setAutoFilledFields(new Set());
return;
}
const selectedServer = servers.find((s) => s.id === id);
if (!selectedServer) return;
const currentValues = form.getValues();
const fieldsToFill: string[] = [];
if (!currentValues.name || autoFilledFields.has("name")) {
form.setValue("name", selectedServer.name as string, {
shouldDirty: false,
});
fieldsToFill.push("name");
}
if (!currentValues.address || autoFilledFields.has("address")) {
form.setValue("address", selectedServer.address as string, {
shouldDirty: false,
});
fieldsToFill.push("address");
}
const protocols = getAvailableProtocols(id);
const firstProtocol = protocols[0];
if (
firstProtocol &&
(!currentValues.protocol || autoFilledFields.has("protocol"))
) {
form.setValue("protocol", firstProtocol.protocol, { shouldDirty: false });
fieldsToFill.push("protocol");
if (
!currentValues.port ||
currentValues.port === 0 ||
autoFilledFields.has("port")
) {
const port = firstProtocol.port || 0;
form.setValue("port", port, { shouldDirty: false });
fieldsToFill.push("port");
}
}
setAutoFilledFields(new Set(fieldsToFill));
}
const handleManualFieldChange = (
fieldName: keyof NodeFormValues,
value: any
) => {
form.setValue(fieldName, value);
removeAutoFilledField(fieldName);
};
function handleProtocolChange(nextProto?: ProtocolName | null) {
const protocol = (nextProto || "") as ProtocolName | "";
form.setValue("protocol", protocol);
if (!(protocol && serverId)) {
removeAutoFilledField("protocol");
return;
}
const currentValues = form.getValues();
const isPortAutoFilled = autoFilledFields.has("port");
removeAutoFilledField("protocol");
if (!currentValues.port || currentValues.port === 0 || isPortAutoFilled) {
const protocolData = availableProtocols.find(
(p) => p.protocol === protocol
);
if (protocolData) {
const port = protocolData.port || 0;
form.setValue("port", port, { shouldDirty: false });
addAutoFilledField("port");
}
}
}
async function handleSubmit(values: NodeFormValues) {
const result = await onSubmit(values);
if (result) {
setOpen(false);
setAutoFilledFields(new Set());
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
form.reset();
setAutoFilledFields(new Set());
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[560px] max-w-full">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6 pt-4">
<Form {...form}>
<form className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="server_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("server", "Server")}</FormLabel>
<FormControl>
<Combobox<number, false>
onChange={(v) => handleServerChange(v)}
options={servers.map((s) => ({
value: s.id,
label: `${s.name} (${(s.address as any) || ""})`,
}))}
placeholder={t("select_server", "Select server…")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("protocol", "Protocol")}</FormLabel>
<FormControl>
<Combobox<string, false>
onChange={(v) =>
handleProtocolChange((v as ProtocolName) || null)
}
options={availableProtocols.map((p) => ({
value: p.protocol,
label: `${p.protocol}${p.port ? ` (${p.port})` : ""}`,
}))}
placeholder={t("select_protocol", "Select protocol…")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) =>
handleManualFieldChange("name", v as string)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>{t("address", "Address")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) =>
handleManualFieldChange("address", v as string)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("port", "Port")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
max={65_535}
min={1}
onValueChange={(v) =>
handleManualFieldChange("port", Number(v))
}
placeholder="1-65535"
type="number"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("tags", "Tags")}</FormLabel>
<FormControl>
<TagInput
onChange={(v) => form.setValue(field.name, v)}
options={existingTags}
placeholder={t(
"tags_placeholder",
"Use Enter or comma (,) to add multiple tags"
)}
value={field.value || []}
/>
</FormControl>
<FormDescription>
{t(
"tags_description",
"Permission grouping tag (incl. plan binding and delivery policies)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button
disabled={loading}
onClick={form.handleSubmit(handleSubmit, (errors) => {
const key = Object.keys(errors)[0] as keyof typeof errors;
if (key) toast.error(String(errors[key]?.message));
return false;
})}
>
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
import { Separator } from "@workspace/ui/components/separator";
import { Combobox } from "@workspace/ui/composed/combobox";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
getOrderList,
updateOrderStatus,
} from "@workspace/ui/services/admin/order";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
export default function Order() {
const { t } = useTranslation("order");
const statusOptions = [
{
value: 1,
label: t("status.1", "Pending"),
className: "bg-orange-500",
},
{ value: 2, label: t("status.2", "Paid"), className: "bg-green-500" },
{
value: 3,
label: t("status.3", "Cancelled"),
className: "bg-gray-500",
},
{ value: 4, label: t("status.4", "Closed"), className: "bg-red-500" },
{
value: 5,
label: t("status.5", "Completed"),
className: "bg-green-500",
},
];
const typeOptions = [
{ value: 1, label: t("type.1", "New Purchase") },
{ value: 2, label: t("type.2", "Renewal") },
{ value: 3, label: t("type.3", "Reset Traffic") },
{ value: 4, label: t("type.4", "Recharge") },
];
const ref = useRef<ProTableActions>(null);
const { subscribes, getSubscribeName } = useSubscribe();
return (
<ProTable<API.Order, any>
action={ref}
columns={[
{
accessorKey: "order_no",
header: t("orderNumber", "Order Number"),
},
{
accessorKey: "type",
header: t("type.0", "Type"),
cell: ({ row }) => {
const type = row.getValue("type") as number;
return (
typeOptions.find((opt) => opt.value === type)?.label ||
t(`type.${type}`)
);
},
},
{
accessorKey: "subscribe_id",
header: t("subscribe", "Subscribe"),
cell: ({ row }) => {
const order = row.original as API.Order;
if (order.type === 4) {
const type = row.getValue("type") as number;
return (
typeOptions.find((opt) => opt.value === type)?.label ||
t(`type.${type}`)
);
}
const name = getSubscribeName(order.subscribe_id);
const quantity = order.quantity;
return name ? `${name} × ${quantity}` : "";
},
},
{
accessorKey: "amount",
header: t("amount", "Amount"),
cell: ({ row }) => {
const order = row.original as API.Order;
return (
<HoverCard>
<HoverCardTrigger asChild>
<Button className="p-0" variant="link">
<Display type="currency" value={order.amount} />
</Button>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-3">
{order.trade_no && (
<>
<div className="font-semibold">
{t("tradeNo", "Transaction Number")}
</div>
<span className="text-muted-foreground">
{order.trade_no}
</span>
<Separator className="my-2" />
</>
)}
<ul className="grid gap-3">
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("subscribePrice", "Subscription Price")}
</span>
<span>
<Display type="currency" value={order.price} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("discount", "Discount Amount")}
</span>
<span>
<Display type="currency" value={order.discount} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("couponDiscount", "Coupon Discount")}
</span>
<span>
<Display
type="currency"
value={order.coupon_discount}
/>
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("feeAmount", "Fee Amount")}
</span>
<span>
<Display type="currency" value={order.fee_amount} />
</span>
</li>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">
{t("total", "Total")}
</span>
<span>
<Display type="currency" value={order.amount} />
</span>
</li>
</ul>
</div>
<Separator className="my-4" />
<ul className="grid gap-3">
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("method", "Payment Method")}
</span>
<span>
{order.payment?.name || order.payment?.platform}
</span>
</li>
</ul>
</HoverCardContent>
</HoverCard>
);
},
},
{
accessorKey: "user_id",
header: t("user", "User"),
cell: ({ row }) => {
const order = row.original as API.Order;
return <UserDetail id={order.user_id} />;
},
},
{
accessorKey: "updated_at",
header: t("updateTime", "Update Time"),
cell: ({ row }) => {
const order = row.original as API.Order;
return formatDate(order.updated_at);
},
},
{
accessorKey: "status",
header: t("status.0", "Status"),
cell: ({ row }) => {
const order = row.original as API.Order;
const option = statusOptions.find(
(opt) => opt.value === order.status
);
if ([1, 3, 4].includes(row.getValue("status"))) {
return (
<Combobox<number, false>
className={cn(option?.className)}
onChange={async (value) => {
await updateOrderStatus({
id: order.id,
status: value,
});
ref.current?.refresh();
}}
options={statusOptions}
placeholder={t("status.0", "Status")}
value={order.status}
/>
);
}
return (
<Badge>
{option?.label || t(`status.${row.getValue("status")}`)}
</Badge>
);
},
},
]}
params={[
{
key: "status",
placeholder: t("status.0", "Status"),
options: statusOptions.map((item) => ({
label: item.label,
value: String(item.value),
})),
},
{
key: "subscribe_id",
placeholder: `${t("subscribe", "Subscribe")}`,
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{ key: "search" },
{
key: "user_id",
placeholder: `${t("user", "User")} ID`,
options: undefined,
},
]}
request={async (pagination, filter) => {
const { data } = await getOrderList({ ...pagination, ...filter });
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Billing from "../dashboard/components/billing";
import PaymentTable from "./payment-table";
export default function Payment() {
return (
<>
<PaymentTable />
<div className="mt-5 flex flex-col gap-3">
<Billing type="payment" />
</div>
</>
);
}
@@ -0,0 +1,470 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { MarkdownEditor } from "@workspace/ui/composed/editor/markdown";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { getPaymentPlatform } from "@workspace/ui/services/admin/payment";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import { useGlobalStore } from "@/stores/global";
interface PaymentFormProps<T extends { platform?: string }> {
trigger: React.ReactNode;
title: string;
loading?: boolean;
initialValues?: T;
onSubmit: (values: T) => Promise<boolean>;
isEdit?: boolean;
}
export default function PaymentForm<T extends { platform?: string }>({
trigger,
title,
loading,
initialValues,
onSubmit,
isEdit,
}: PaymentFormProps<T>) {
const { t } = useTranslation("payment");
const { common } = useGlobalStore();
const { currency } = common;
const [open, setOpen] = useState(false);
const { data: platformData } = useQuery({
queryKey: ["getPaymentPlatform"],
queryFn: async () => {
const { data } = await getPaymentPlatform();
return data?.data?.list || [];
},
});
const formSchema = z.object({
name: z.string().min(1, { message: t("nameRequired", "Name is required") }),
platform: z.string().optional(),
icon: z.string().optional(),
domain: z.string().optional(),
config: z.any(),
fee_mode: z.number().min(0).max(2),
fee_percent: z.number().optional(),
fee_amount: z.number().optional(),
description: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
platform: "",
icon: "",
domain: "",
config: {},
fee_mode: 0,
fee_percent: 0,
fee_amount: 0,
...(initialValues as any),
},
});
const feeMode = form.watch("fee_mode");
const platformValue = form.watch("platform");
const configValues = form.watch("config");
const currentPlatform = platformData?.find(
(p) => p.platform === platformValue
);
const currentFieldDescriptions =
currentPlatform?.platform_field_description || {};
const configFields = Object.keys(currentFieldDescriptions) || [];
const platformUrl = currentPlatform?.platform_url || "";
useEffect(() => {
if (feeMode === 0) {
form.setValue("fee_amount", 0);
form.setValue("fee_percent", 0);
} else if (feeMode === 1) {
form.setValue("fee_amount", 0);
} else if (feeMode === 2) {
form.setValue("fee_percent", 0);
}
}, [feeMode, form]);
const handleClose = () => {
form.reset();
setOpen(false);
};
const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const cleanedValues = { ...values };
if (values.fee_mode === 0) {
cleanedValues.fee_amount = undefined;
cleanedValues.fee_percent = undefined;
} else if (values.fee_mode === 1) {
cleanedValues.fee_amount = undefined;
} else if (values.fee_mode === 2) {
cleanedValues.fee_percent = undefined;
}
const success = await onSubmit(cleanedValues as unknown as T);
if (success) {
handleClose();
}
};
const openPlatformUrl = () => {
if (platformUrl) {
window.open(platformUrl, "_blank");
}
};
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>{trigger}</SheetTrigger>
<SheetContent className="w-[550px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-24px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-6 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
form.setValue("name", value as string)
}
placeholder={t(
"namePlaceholder",
"Enter payment method name"
)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>{t("icon", "Icon")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
form.setValue("icon", value as string)
}
placeholder={t("iconPlaceholder", "Enter icon URL")}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain", "Domain")}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
form.setValue("domain", value as string)
}
placeholder="http(s)://example.com"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="fee_mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("handlingFee", "Handling Fee")}</FormLabel>
<FormControl>
<RadioGroup
className="flex flex-wrap gap-4"
onValueChange={(value) =>
field.onChange(Number.parseInt(value, 10))
}
value={field.value.toString()}
>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem value="0" />
</FormControl>
<FormLabel className="!mt-0 cursor-pointer">
{t("noFee", "No Fee")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem value="1" />
</FormControl>
<FormLabel className="!mt-0 cursor-pointer">
{t("percentFee", "Percentage")}
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-2">
<FormControl>
<RadioGroupItem value="2" />
</FormControl>
<FormLabel className="!mt-0 cursor-pointer">
{t("fixedFee", "Fixed Amount")}
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{feeMode === 1 && (
<div className="grid grid-cols-1 sm:w-1/2">
<FormField
control={form.control}
name="fee_percent"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("feePercent", "Fee Percentage")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
step="0.01"
suffix="%"
type="number"
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{feeMode === 2 && (
<div className="grid grid-cols-1 sm:w-1/2">
<FormField
control={form.control}
name="fee_amount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("feeAmount", "Fixed Amount")}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) =>
field.onChange(
unitConversion("dollarsToCents", value)
)
}
prefix={currency.currency_symbol}
step="0.01"
suffix={currency.currency_unit}
type="number"
value={unitConversion(
"centsToDollars",
field.value
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<div className="space-y-4">
{(!platformValue ||
platformData?.find((p) => p.platform === platformValue)) && (
<FormField
control={form.control}
name="platform"
render={({ field }) => (
<FormItem>
<FormLabel>{t("platform", "Platform")}</FormLabel>
<Select
defaultValue={field.value}
disabled={isEdit && Boolean(initialValues?.platform)}
onValueChange={(value) => {
form.setValue("platform", value as string);
form.setValue("config", {});
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectPlatform",
"Select Platform"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{platformData?.map((platform) => (
<SelectItem
key={platform.platform}
value={platform.platform}
>
{platform.platform}
</SelectItem>
))}
</SelectContent>
</Select>
{platformUrl ? (
<div className="mt-1 flex justify-end">
<Button
className="h-6 px-2 text-xs"
onClick={openPlatformUrl}
size="sm"
variant="ghost"
>
<Icon
className="mr-1 h-3 w-3"
icon="tabler:external-link"
/>
{t("applyForPayment", "Apply for Payment")}
</Button>
</div>
) : (
<div className="mt-1 h-6" />
)}
<FormMessage />
</FormItem>
)}
/>
)}
{configFields.length > 0 && (
<div className="mt-4 space-y-4">
{configFields.map((fieldKey) => (
<FormItem key={fieldKey}>
<FormLabel>
{currentFieldDescriptions[fieldKey]}
</FormLabel>
<FormControl>
<EnhancedInput
disabled={fieldKey === "webhook_secret"}
onValueChange={(value) => {
const newConfig = { ...configValues };
newConfig[fieldKey] = value;
form.setValue("config", newConfig);
}}
placeholder={t("configPlaceholder", {
field: currentFieldDescriptions[fieldKey],
defaultValue:
"Please fill in the provided {{field}} configuration",
})}
value={
configValues &&
configValues[fieldKey] !== undefined
? configValues[fieldKey]
: ""
}
/>
</FormControl>
</FormItem>
))}
</div>
)}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t("description", "Description")}</FormLabel>
<FormControl>
<MarkdownEditor
onChange={(value: string | undefined) =>
form.setValue(field.name, value as string)
}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button disabled={loading} onClick={handleClose} variant="outline">
{t("cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("submit", "Submit")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,241 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createPaymentMethod,
deletePaymentMethod,
getPaymentMethodList,
updatePaymentMethod,
} from "@workspace/ui/services/admin/payment";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import PaymentForm from "./payment-form";
export default function PaymentTable() {
const { t } = useTranslation("payment");
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<ProTable<API.PaymentConfig, { search: string }>
action={ref}
actions={{
render: (row) => [
<PaymentForm<API.UpdatePaymentMethodRequest>
initialValues={row}
isEdit
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updatePaymentMethod({
...row,
...values,
});
toast.success(t("updateSuccess", "Updated successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("editPayment", "Edit Payment Method")}
trigger={<Button>{t("edit", "Edit")}</Button>}
/>,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Are you sure you want to delete this payment method? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deletePaymentMethod({
id: row.id,
});
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">{t("delete", "Delete")}</Button>
}
/>,
<Button
key="copy"
onClick={async () => {
setLoading(true);
try {
const { id: _id, ...params } = row;
await createPaymentMethod({
...params,
enable: false,
});
toast.success(t("copySuccess", "Copied successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
variant="outline"
>
{t("copy", "Copy")}
</Button>,
],
batchRender(rows) {
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"deleteWarning",
"Are you sure you want to delete this payment method? This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
for (const row of rows) {
await deletePaymentMethod({ id: row.id });
}
toast.success(t("deleteSuccess", "Deleted successfully"));
ref.current?.refresh();
}}
title={t("confirmDelete", "Confirm Delete")}
trigger={
<Button variant="destructive">
{t("batchDelete", "Batch Delete")}
</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "enable",
header: t("enable", "Enable"),
cell: ({ row }) => (
<Switch
checked={Boolean(row.getValue("enable"))}
onCheckedChange={async (checked) => {
await updatePaymentMethod({
...row.original,
enable: checked,
});
ref.current?.refresh();
}}
/>
),
},
{
accessorKey: "icon",
header: t("icon", "Icon"),
cell: ({ row }) => {
const icon = row.getValue("icon") as string;
return (
<Avatar className="h-8 w-8">
{icon ? (
<AvatarImage alt={row.getValue("name")} src={icon} />
) : null}
<AvatarFallback>
{(row.getValue("name") as string)?.charAt(0) || "?"}
</AvatarFallback>
</Avatar>
);
},
},
{
accessorKey: "name",
header: t("name", "Name"),
},
{
accessorKey: "platform",
header: t("platform", "Platform"),
cell: ({ row }) => <Badge>{t(row.original.platform)}</Badge>,
},
{
accessorKey: "notify_url",
header: t("notify_url", "Notify URL"),
},
{
accessorKey: "fee",
header: t("handlingFee", "Handling Fee"),
cell: ({ row }) => {
const feeMode = row.original.fee_mode;
if (feeMode === 1) {
return <Badge>{row.original.fee_percent}%</Badge>;
}
if (feeMode === 2) {
return (
<Badge>
<Display type="currency" value={row.original.fee_amount} />
</Badge>
);
}
return "--";
},
},
]}
header={{
title: t("paymentManagement", "Payment Management"),
toolbar: (
<PaymentForm<API.CreatePaymentMethodRequest>
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createPaymentMethod({
...values,
enable: false,
});
toast.success(t("createSuccess", "Created successfully"));
ref.current?.refresh();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("createPayment", "Add Payment Method")}
trigger={<Button>{t("create", "Add Payment Method")}</Button>}
/>
),
}}
params={[
{
key: "search",
placeholder: t("searchPlaceholder", "Enter search terms"),
},
]}
request={async (pagination, filter) => {
const { data } = await getPaymentMethodList({
...pagination,
...filter,
});
return {
list: data?.data?.list || [],
total: data?.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,5 @@
import SubscribeTable from "./subscribe-table";
export default function Product() {
return <SubscribeTable />;
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More