🎉 feat: initialization
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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>()
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import Auth from "@/sections/auth";
|
||||
|
||||
export const Route = createLazyFileRoute("/")({
|
||||
component: Auth,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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?")}
|
||||
<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?")}
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm("login");
|
||||
}}
|
||||
variant="link"
|
||||
>
|
||||
{t("reset.switchToLogin", "Login")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user