🎉 feat: initialization

This commit is contained in:
web
2025-11-26 19:56:16 -08:00
commit a801849fb2
553 changed files with 213088 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { formatBytes } from "@workspace/ui/utils/formatting";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
type DisplayType = "currency" | "traffic" | "number" | "trafficSpeed";
interface DisplayProps<T> {
value?: T;
unlimited?: boolean;
type?: DisplayType;
}
export function Display<T extends number | undefined | null>({
value = 0,
unlimited = false,
type = "number",
}: DisplayProps<T>): string {
const { t } = useTranslation("components");
const { common } = useGlobalStore();
const { currency } = common;
if (type === "currency") {
const formattedValue = `${currency?.currency_symbol ?? ""}${unitConversion("centsToDollars", value as number)?.toFixed(2) ?? "0.00"}`;
return formattedValue;
}
if (
["traffic", "trafficSpeed", "number"].includes(type) &&
unlimited &&
!value
) {
return t("unlimited");
}
if (type === "traffic") {
return value ? formatBytes(value) : "0";
}
if (type === "trafficSpeed") {
return value ? `${formatBytes(value).replace("B", "b")}ps` : "0";
}
if (type === "number") {
return value ? value.toString() : "0";
}
return "0";
}
+9
View File
@@ -0,0 +1,9 @@
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;
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { Separator } from "@workspace/ui/components/separator";
import { Icon } from "@workspace/ui/composed/icon";
import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
interface CustomData {
community?: {
discord?: string;
facebook?: string;
github?: string;
instagram?: string;
linkedin?: string;
telegram?: string;
twitter?: string;
};
contacts?: {
address?: string;
email?: string;
telephone?: string;
};
website?: string;
}
export default function Footer() {
const { t } = useTranslation("components");
const { common } = useGlobalStore();
const { site } = common;
const customData = useMemo<CustomData>(() => {
try {
return JSON.parse(site.custom_data || "{}");
} catch {
return {};
}
}, [site.custom_data]);
const links = useMemo(
() => [
{
name: "email",
icon: "uil:envelope",
href: customData.contacts?.email
? `mailto:${customData.contacts.email}`
: undefined,
},
{
name: "telegram",
icon: "uil:telegram",
href: customData.community?.telegram,
},
{
name: "twitter",
icon: "uil:twitter",
href: customData.community?.twitter,
},
{
name: "discord",
icon: "uil:discord",
href: customData.community?.discord,
},
{
name: "instagram",
icon: "uil:instagram",
href: customData.community?.instagram,
},
{
name: "linkedin",
icon: "uil:linkedin",
href: customData.community?.linkedin,
},
{
name: "github",
icon: "uil:github",
href: customData.community?.github,
},
{
name: "facebook",
icon: "uil:facebook",
href: customData.community?.facebook,
},
],
[customData]
);
return (
<footer>
<Separator className="my-14" />
<div className="container mb-14 flex flex-wrap justify-between gap-4 text-muted-foreground text-sm">
<nav className="flex flex-wrap items-center gap-2">
{links
.filter((item) => item.href)
.map((item, index) => (
<Fragment key={index}>
{index !== 0 && <Separator orientation="vertical" />}
<a
aria-label={t(
`footer.social.${item.name}`,
`Visit our ${item.name}`
)}
href={item.href}
rel="noopener noreferrer"
target="_blank"
>
<Icon className="size-5 text-foreground" icon={item.icon} />
</a>
</Fragment>
))}
</nav>
<div>
<strong className="text-foreground">{site.site_name}</strong> ©{" "}
{t("footer.copyright", "All rights reserved")}.
<div>
<a className="underline" href="/tos">
{t("footer.tos", "Terms of Service")}
</a>
<a className="ml-2 underline" href="/privacy-policy">
{t("footer.privacyPolicy", "Privacy Policy")}
</a>
</div>
</div>
</div>
</footer>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { Link } from "@tanstack/react-router";
import { buttonVariants } from "@workspace/ui/components/button";
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
import { UserNav } from "./user-nav";
export default function Header() {
const { t } = useTranslation("components");
const { common, user } = useGlobalStore();
const { site } = common;
const Logo = (
<Link className="flex items-center gap-2 font-bold text-lg" to="/">
{site.site_logo && (
<img alt="logo" height={36} src={site.site_logo} width={36} />
)}
<span className="">{site.site_name}</span>
</Link>
);
return (
<header className="sticky top-0 z-50 border-b backdrop-blur-md">
<div className="container flex h-16 items-center justify-between">
<nav className="flex-col gap-6 font-medium text-lg md:flex md:flex-row md:items-center md:gap-5 md:text-sm lg:gap-6">
{Logo}
</nav>
<div className="flex flex-1 items-center justify-end gap-2">
<LanguageSwitch />
<ThemeSwitch />
<UserNav />
{!user && (
<Link
className={buttonVariants({
size: "sm",
})}
to="/auth"
>
{t("loginRegister", "Login / Register")}
</Link>
)}
</div>
</div>
</header>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useTranslation } from "react-i18next";
export interface NavItem {
title: string;
url: string;
icon: string;
}
export interface NavGroup {
title: string;
url?: string;
icon?: string;
items?: NavItem[];
}
export function useNavs() {
const { t } = useTranslation("components");
const navs: NavGroup[] = [
{
title: t("menu.dashboard", "Dashboard"),
url: "/dashboard",
icon: "uil:dashboard",
},
{
title: t("menu.personal", "Personal"),
items: [
{
title: t("menu.profile", "User Detail"),
url: "/profile",
icon: "uil:user",
},
],
},
{
title: t("menu.server", "Server Management"),
items: [
{
url: "/subscribe",
icon: "uil:shop",
title: t("menu.subscribe", "Subscribe"),
},
],
},
{
title: t("menu.finance", "Commerce"),
items: [
{
url: "/order",
icon: "uil:notes",
title: t("menu.order", "Order Management"),
},
{
url: "/wallet",
icon: "uil:wallet",
title: t("menu.wallet", "Balance"),
},
{
url: "/affiliate",
icon: "uil:users-alt",
title: t("menu.affiliate", "Commission"),
},
],
},
{
title: t("menu.help", "Users & Support"),
items: [
{
url: "/document",
icon: "uil:book-alt",
title: t("menu.document", "Document Management"),
},
{
url: "/announcement",
icon: "uil:megaphone",
title: t("menu.announcement", "Announcement Management"),
},
{
url: "/ticket",
icon: "uil:message",
title: t("menu.ticket", "Ticket Management"),
},
],
},
];
return navs;
}
export function useFindNavByUrl(url: string) {
const navs = useNavs();
for (const nav of navs) {
if (nav.url && nav.url === url) {
return [nav];
}
if (nav.items) {
const current = nav.items.find((item) => item.url === url);
if (current) {
return [nav, current];
}
}
}
return [];
}
export function useNavItems() {
const { t } = useTranslation("components");
return [
{
url: "/profile",
icon: "uil:user",
title: t("menu.profile", "User Detail"),
},
{
url: "/subscribe",
icon: "uil:shop",
title: t("menu.subscribe", "Subscribe"),
},
{
url: "/order",
icon: "uil:notes",
title: t("menu.order", "Order Management"),
},
{
url: "/wallet",
icon: "uil:wallet",
title: t("menu.wallet", "Balance"),
},
];
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useNavigate } from "@tanstack/react-router";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Icon } from "@workspace/ui/composed/icon";
import { removeCookie } from "@workspace/ui/lib/cookies";
import { useTranslation } from "react-i18next";
import { useNavs } from "@/layout/navs";
import { useGlobalStore } from "@/stores/global";
export function UserNav() {
const { t } = useTranslation("components");
const { user, setUser } = useGlobalStore();
const navigate = useNavigate();
const navs = useNavs();
const handleLogout = () => {
removeCookie("Authorization");
setUser(undefined);
navigate({ to: "/" });
};
if (user) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex cursor-pointer items-center gap-2 rounded-full border bg-background px-2 py-1.5 transition-colors duration-200 hover:bg-accent">
<Avatar className="h-6 w-6">
<AvatarImage
alt={user?.avatar ?? ""}
className="object-cover"
src={user?.auth_methods?.[0]?.auth_identifier ?? ""}
/>
<AvatarFallback className="bg-gradient-to-br from-primary/90 to-primary font-medium text-background">
{user?.auth_methods?.[0]?.auth_identifier
.toUpperCase()
.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="max-w-[40px] truncate text-sm sm:max-w-[100px]">
{user?.auth_methods?.[0]?.auth_identifier.split("@")[0]}
</span>
<Icon
className="size-4 text-muted-foreground"
icon="mdi:chevron-down"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" forceMount>
<div className="flex items-center justify-start gap-2 p-2">
<Avatar className="h-10 w-10">
<AvatarImage
alt={user?.avatar ?? ""}
className="object-cover"
src={user?.avatar ?? ""}
/>
<AvatarFallback className="bg-gradient-to-br from-primary/90 to-primary text-background">
{user?.auth_methods?.[0]?.auth_identifier
.toUpperCase()
.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col space-y-0.5">
<p className="font-medium text-sm leading-none">
{user?.auth_methods?.[0]?.auth_identifier.split("@")[0]}
</p>
<p className="text-muted-foreground text-xs">
{user?.auth_methods?.[0]?.auth_identifier}
</p>
</div>
</div>
<DropdownMenuSeparator />
{navs.map((nav) => (
<DropdownMenuGroup key={nav.title}>
{(nav.items || [nav]).map((item) => (
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 py-2"
key={item.title}
onClick={() => {
navigate({ to: item.url });
}}
>
<Icon
className="size-4 flex-none text-muted-foreground"
icon={item.icon as string}
/>
<span className="flex-grow truncate">{item.title}</span>
<Icon
className="size-4 text-muted-foreground opacity-50"
icon="lucide:chevron-right"
/>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 py-2 text-destructive focus:text-destructive"
onClick={handleLogout}
>
<Icon className="size-4 flex-none" icon="uil:exit" />
<span className="flex-grow">{t("menu.logout", "Logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}
+86
View File
@@ -0,0 +1,86 @@
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: [
"affiliate",
"auth",
"components",
"dashboard",
"document",
"layout",
"main",
"order",
"payment",
"profile",
"subscribe",
"ticket",
"wallet",
],
});
window.logout = Logout;
// Create a new router instance
const TanStackQueryProviderContext = TanStackQueryContext();
const router = createRouter({
routeTree,
context: {
...TanStackQueryProviderContext,
},
defaultPreload: "intent",
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
});
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// Render the app
const rootElement = document.getElementById("app");
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<TanStackQueryProvider {...TanStackQueryProviderContext}>
<LanguageProvider supportedLanguages={supportedLngs}>
<ThemeProvider>
<DirectionProvider>
<RouterProvider router={router} />
</DirectionProvider>
</ThemeProvider>
</LanguageProvider>
</TanStackQueryProvider>
</StrictMode>
);
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+13
View File
@@ -0,0 +1,13 @@
const reportWebVitals = (onPerfEntry?: () => void) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
onCLS(onPerfEntry);
onINP(onPerfEntry);
onFCP(onPerfEntry);
onLCP(onPerfEntry);
onTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+546
View File
@@ -0,0 +1,546 @@
/* 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'
import { Route as mainPurchasingOrderIndexRouteImport } from './routes/(main)/purchasing/order/index'
const mainRouteLazyRouteImport = createFileRoute('/(main)')()
const AuthIndexLazyRouteImport = createFileRoute('/auth/')()
const mainIndexLazyRouteImport = createFileRoute('/(main)/')()
const OauthPlatformLazyRouteImport = createFileRoute('/oauth/$platform')()
const BindPlatformLazyRouteImport = createFileRoute('/bind/$platform')()
const mainTosLazyRouteImport = createFileRoute('/(main)/tos')()
const mainPrivacyPolicyLazyRouteImport = createFileRoute(
'/(main)/privacy-policy',
)()
const mainPaymentLazyRouteImport = createFileRoute('/(main)/payment')()
const mainuserRouteLazyRouteImport = createFileRoute('/(main)/(user)')()
const mainPurchasingIndexLazyRouteImport = createFileRoute(
'/(main)/purchasing/',
)()
const mainuserWalletLazyRouteImport = createFileRoute('/(main)/(user)/wallet')()
const mainuserTicketLazyRouteImport = createFileRoute('/(main)/(user)/ticket')()
const mainuserSubscribeLazyRouteImport = createFileRoute(
'/(main)/(user)/subscribe',
)()
const mainuserProfileLazyRouteImport = createFileRoute(
'/(main)/(user)/profile',
)()
const mainuserOrderLazyRouteImport = createFileRoute('/(main)/(user)/order')()
const mainuserDocumentLazyRouteImport = createFileRoute(
'/(main)/(user)/document',
)()
const mainuserDashboardLazyRouteImport = createFileRoute(
'/(main)/(user)/dashboard',
)()
const mainuserAnnouncementLazyRouteImport = createFileRoute(
'/(main)/(user)/announcement',
)()
const mainuserAffiliateLazyRouteImport = createFileRoute(
'/(main)/(user)/affiliate',
)()
const mainRouteLazyRoute = mainRouteLazyRouteImport
.update({
id: '/(main)',
getParentRoute: () => rootRouteImport,
} as any)
.lazy(() => import('./routes/(main)/route.lazy').then((d) => d.Route))
const AuthIndexLazyRoute = AuthIndexLazyRouteImport.update({
id: '/auth/',
path: '/auth/',
getParentRoute: () => rootRouteImport,
} as any).lazy(() => import('./routes/auth/index.lazy').then((d) => d.Route))
const mainIndexLazyRoute = mainIndexLazyRouteImport
.update({
id: '/',
path: '/',
getParentRoute: () => mainRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/index.lazy').then((d) => d.Route))
const OauthPlatformLazyRoute = OauthPlatformLazyRouteImport.update({
id: '/oauth/$platform',
path: '/oauth/$platform',
getParentRoute: () => rootRouteImport,
} as any).lazy(() =>
import('./routes/oauth/$platform.lazy').then((d) => d.Route),
)
const BindPlatformLazyRoute = BindPlatformLazyRouteImport.update({
id: '/bind/$platform',
path: '/bind/$platform',
getParentRoute: () => rootRouteImport,
} as any).lazy(() =>
import('./routes/bind/$platform.lazy').then((d) => d.Route),
)
const mainTosLazyRoute = mainTosLazyRouteImport
.update({
id: '/tos',
path: '/tos',
getParentRoute: () => mainRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/tos.lazy').then((d) => d.Route))
const mainPrivacyPolicyLazyRoute = mainPrivacyPolicyLazyRouteImport
.update({
id: '/privacy-policy',
path: '/privacy-policy',
getParentRoute: () => mainRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/privacy-policy.lazy').then((d) => d.Route),
)
const mainPaymentLazyRoute = mainPaymentLazyRouteImport
.update({
id: '/payment',
path: '/payment',
getParentRoute: () => mainRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/payment.lazy').then((d) => d.Route))
const mainuserRouteLazyRoute = mainuserRouteLazyRouteImport
.update({
id: '/(user)',
getParentRoute: () => mainRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/(user)/route.lazy').then((d) => d.Route))
const mainPurchasingIndexLazyRoute = mainPurchasingIndexLazyRouteImport
.update({
id: '/purchasing/',
path: '/purchasing/',
getParentRoute: () => mainRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/purchasing/index.lazy').then((d) => d.Route),
)
const mainuserWalletLazyRoute = mainuserWalletLazyRouteImport
.update({
id: '/wallet',
path: '/wallet',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/(user)/wallet.lazy').then((d) => d.Route))
const mainuserTicketLazyRoute = mainuserTicketLazyRouteImport
.update({
id: '/ticket',
path: '/ticket',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/(user)/ticket.lazy').then((d) => d.Route))
const mainuserSubscribeLazyRoute = mainuserSubscribeLazyRouteImport
.update({
id: '/subscribe',
path: '/subscribe',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/(user)/subscribe.lazy').then((d) => d.Route),
)
const mainuserProfileLazyRoute = mainuserProfileLazyRouteImport
.update({
id: '/profile',
path: '/profile',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/(user)/profile.lazy').then((d) => d.Route),
)
const mainuserOrderLazyRoute = mainuserOrderLazyRouteImport
.update({
id: '/order',
path: '/order',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() => import('./routes/(main)/(user)/order.lazy').then((d) => d.Route))
const mainuserDocumentLazyRoute = mainuserDocumentLazyRouteImport
.update({
id: '/document',
path: '/document',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/(user)/document.lazy').then((d) => d.Route),
)
const mainuserDashboardLazyRoute = mainuserDashboardLazyRouteImport
.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/(user)/dashboard.lazy').then((d) => d.Route),
)
const mainuserAnnouncementLazyRoute = mainuserAnnouncementLazyRouteImport
.update({
id: '/announcement',
path: '/announcement',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/(user)/announcement.lazy').then((d) => d.Route),
)
const mainuserAffiliateLazyRoute = mainuserAffiliateLazyRouteImport
.update({
id: '/affiliate',
path: '/affiliate',
getParentRoute: () => mainuserRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/(user)/affiliate.lazy').then((d) => d.Route),
)
const mainPurchasingOrderIndexRoute = mainPurchasingOrderIndexRouteImport
.update({
id: '/purchasing/order/',
path: '/purchasing/order/',
getParentRoute: () => mainRouteLazyRoute,
} as any)
.lazy(() =>
import('./routes/(main)/purchasing/order/index.lazy').then((d) => d.Route),
)
export interface FileRoutesByFullPath {
'/': typeof mainIndexLazyRoute
'/payment': typeof mainPaymentLazyRoute
'/privacy-policy': typeof mainPrivacyPolicyLazyRoute
'/tos': typeof mainTosLazyRoute
'/bind/$platform': typeof BindPlatformLazyRoute
'/oauth/$platform': typeof OauthPlatformLazyRoute
'/auth': typeof AuthIndexLazyRoute
'/affiliate': typeof mainuserAffiliateLazyRoute
'/announcement': typeof mainuserAnnouncementLazyRoute
'/dashboard': typeof mainuserDashboardLazyRoute
'/document': typeof mainuserDocumentLazyRoute
'/order': typeof mainuserOrderLazyRoute
'/profile': typeof mainuserProfileLazyRoute
'/subscribe': typeof mainuserSubscribeLazyRoute
'/ticket': typeof mainuserTicketLazyRoute
'/wallet': typeof mainuserWalletLazyRoute
'/purchasing': typeof mainPurchasingIndexLazyRoute
'/purchasing/order': typeof mainPurchasingOrderIndexRoute
}
export interface FileRoutesByTo {
'/': typeof mainIndexLazyRoute
'/payment': typeof mainPaymentLazyRoute
'/privacy-policy': typeof mainPrivacyPolicyLazyRoute
'/tos': typeof mainTosLazyRoute
'/bind/$platform': typeof BindPlatformLazyRoute
'/oauth/$platform': typeof OauthPlatformLazyRoute
'/auth': typeof AuthIndexLazyRoute
'/affiliate': typeof mainuserAffiliateLazyRoute
'/announcement': typeof mainuserAnnouncementLazyRoute
'/dashboard': typeof mainuserDashboardLazyRoute
'/document': typeof mainuserDocumentLazyRoute
'/order': typeof mainuserOrderLazyRoute
'/profile': typeof mainuserProfileLazyRoute
'/subscribe': typeof mainuserSubscribeLazyRoute
'/ticket': typeof mainuserTicketLazyRoute
'/wallet': typeof mainuserWalletLazyRoute
'/purchasing': typeof mainPurchasingIndexLazyRoute
'/purchasing/order': typeof mainPurchasingOrderIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/(main)': typeof mainRouteLazyRouteWithChildren
'/(main)/(user)': typeof mainuserRouteLazyRouteWithChildren
'/(main)/payment': typeof mainPaymentLazyRoute
'/(main)/privacy-policy': typeof mainPrivacyPolicyLazyRoute
'/(main)/tos': typeof mainTosLazyRoute
'/bind/$platform': typeof BindPlatformLazyRoute
'/oauth/$platform': typeof OauthPlatformLazyRoute
'/(main)/': typeof mainIndexLazyRoute
'/auth/': typeof AuthIndexLazyRoute
'/(main)/(user)/affiliate': typeof mainuserAffiliateLazyRoute
'/(main)/(user)/announcement': typeof mainuserAnnouncementLazyRoute
'/(main)/(user)/dashboard': typeof mainuserDashboardLazyRoute
'/(main)/(user)/document': typeof mainuserDocumentLazyRoute
'/(main)/(user)/order': typeof mainuserOrderLazyRoute
'/(main)/(user)/profile': typeof mainuserProfileLazyRoute
'/(main)/(user)/subscribe': typeof mainuserSubscribeLazyRoute
'/(main)/(user)/ticket': typeof mainuserTicketLazyRoute
'/(main)/(user)/wallet': typeof mainuserWalletLazyRoute
'/(main)/purchasing/': typeof mainPurchasingIndexLazyRoute
'/(main)/purchasing/order/': typeof mainPurchasingOrderIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/payment'
| '/privacy-policy'
| '/tos'
| '/bind/$platform'
| '/oauth/$platform'
| '/auth'
| '/affiliate'
| '/announcement'
| '/dashboard'
| '/document'
| '/order'
| '/profile'
| '/subscribe'
| '/ticket'
| '/wallet'
| '/purchasing'
| '/purchasing/order'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/payment'
| '/privacy-policy'
| '/tos'
| '/bind/$platform'
| '/oauth/$platform'
| '/auth'
| '/affiliate'
| '/announcement'
| '/dashboard'
| '/document'
| '/order'
| '/profile'
| '/subscribe'
| '/ticket'
| '/wallet'
| '/purchasing'
| '/purchasing/order'
id:
| '__root__'
| '/(main)'
| '/(main)/(user)'
| '/(main)/payment'
| '/(main)/privacy-policy'
| '/(main)/tos'
| '/bind/$platform'
| '/oauth/$platform'
| '/(main)/'
| '/auth/'
| '/(main)/(user)/affiliate'
| '/(main)/(user)/announcement'
| '/(main)/(user)/dashboard'
| '/(main)/(user)/document'
| '/(main)/(user)/order'
| '/(main)/(user)/profile'
| '/(main)/(user)/subscribe'
| '/(main)/(user)/ticket'
| '/(main)/(user)/wallet'
| '/(main)/purchasing/'
| '/(main)/purchasing/order/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
mainRouteLazyRoute: typeof mainRouteLazyRouteWithChildren
BindPlatformLazyRoute: typeof BindPlatformLazyRoute
OauthPlatformLazyRoute: typeof OauthPlatformLazyRoute
AuthIndexLazyRoute: typeof AuthIndexLazyRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/(main)': {
id: '/(main)'
path: '/'
fullPath: '/'
preLoaderRoute: typeof mainRouteLazyRouteImport
parentRoute: typeof rootRouteImport
}
'/auth/': {
id: '/auth/'
path: '/auth'
fullPath: '/auth'
preLoaderRoute: typeof AuthIndexLazyRouteImport
parentRoute: typeof rootRouteImport
}
'/(main)/': {
id: '/(main)/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof mainIndexLazyRouteImport
parentRoute: typeof mainRouteLazyRoute
}
'/oauth/$platform': {
id: '/oauth/$platform'
path: '/oauth/$platform'
fullPath: '/oauth/$platform'
preLoaderRoute: typeof OauthPlatformLazyRouteImport
parentRoute: typeof rootRouteImport
}
'/bind/$platform': {
id: '/bind/$platform'
path: '/bind/$platform'
fullPath: '/bind/$platform'
preLoaderRoute: typeof BindPlatformLazyRouteImport
parentRoute: typeof rootRouteImport
}
'/(main)/tos': {
id: '/(main)/tos'
path: '/tos'
fullPath: '/tos'
preLoaderRoute: typeof mainTosLazyRouteImport
parentRoute: typeof mainRouteLazyRoute
}
'/(main)/privacy-policy': {
id: '/(main)/privacy-policy'
path: '/privacy-policy'
fullPath: '/privacy-policy'
preLoaderRoute: typeof mainPrivacyPolicyLazyRouteImport
parentRoute: typeof mainRouteLazyRoute
}
'/(main)/payment': {
id: '/(main)/payment'
path: '/payment'
fullPath: '/payment'
preLoaderRoute: typeof mainPaymentLazyRouteImport
parentRoute: typeof mainRouteLazyRoute
}
'/(main)/(user)': {
id: '/(main)/(user)'
path: '/'
fullPath: '/'
preLoaderRoute: typeof mainuserRouteLazyRouteImport
parentRoute: typeof mainRouteLazyRoute
}
'/(main)/purchasing/': {
id: '/(main)/purchasing/'
path: '/purchasing'
fullPath: '/purchasing'
preLoaderRoute: typeof mainPurchasingIndexLazyRouteImport
parentRoute: typeof mainRouteLazyRoute
}
'/(main)/(user)/wallet': {
id: '/(main)/(user)/wallet'
path: '/wallet'
fullPath: '/wallet'
preLoaderRoute: typeof mainuserWalletLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/ticket': {
id: '/(main)/(user)/ticket'
path: '/ticket'
fullPath: '/ticket'
preLoaderRoute: typeof mainuserTicketLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/subscribe': {
id: '/(main)/(user)/subscribe'
path: '/subscribe'
fullPath: '/subscribe'
preLoaderRoute: typeof mainuserSubscribeLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/profile': {
id: '/(main)/(user)/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof mainuserProfileLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/order': {
id: '/(main)/(user)/order'
path: '/order'
fullPath: '/order'
preLoaderRoute: typeof mainuserOrderLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/document': {
id: '/(main)/(user)/document'
path: '/document'
fullPath: '/document'
preLoaderRoute: typeof mainuserDocumentLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/dashboard': {
id: '/(main)/(user)/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof mainuserDashboardLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/announcement': {
id: '/(main)/(user)/announcement'
path: '/announcement'
fullPath: '/announcement'
preLoaderRoute: typeof mainuserAnnouncementLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/(user)/affiliate': {
id: '/(main)/(user)/affiliate'
path: '/affiliate'
fullPath: '/affiliate'
preLoaderRoute: typeof mainuserAffiliateLazyRouteImport
parentRoute: typeof mainuserRouteLazyRoute
}
'/(main)/purchasing/order/': {
id: '/(main)/purchasing/order/'
path: '/purchasing/order'
fullPath: '/purchasing/order'
preLoaderRoute: typeof mainPurchasingOrderIndexRouteImport
parentRoute: typeof mainRouteLazyRoute
}
}
}
interface mainuserRouteLazyRouteChildren {
mainuserAffiliateLazyRoute: typeof mainuserAffiliateLazyRoute
mainuserAnnouncementLazyRoute: typeof mainuserAnnouncementLazyRoute
mainuserDashboardLazyRoute: typeof mainuserDashboardLazyRoute
mainuserDocumentLazyRoute: typeof mainuserDocumentLazyRoute
mainuserOrderLazyRoute: typeof mainuserOrderLazyRoute
mainuserProfileLazyRoute: typeof mainuserProfileLazyRoute
mainuserSubscribeLazyRoute: typeof mainuserSubscribeLazyRoute
mainuserTicketLazyRoute: typeof mainuserTicketLazyRoute
mainuserWalletLazyRoute: typeof mainuserWalletLazyRoute
}
const mainuserRouteLazyRouteChildren: mainuserRouteLazyRouteChildren = {
mainuserAffiliateLazyRoute: mainuserAffiliateLazyRoute,
mainuserAnnouncementLazyRoute: mainuserAnnouncementLazyRoute,
mainuserDashboardLazyRoute: mainuserDashboardLazyRoute,
mainuserDocumentLazyRoute: mainuserDocumentLazyRoute,
mainuserOrderLazyRoute: mainuserOrderLazyRoute,
mainuserProfileLazyRoute: mainuserProfileLazyRoute,
mainuserSubscribeLazyRoute: mainuserSubscribeLazyRoute,
mainuserTicketLazyRoute: mainuserTicketLazyRoute,
mainuserWalletLazyRoute: mainuserWalletLazyRoute,
}
const mainuserRouteLazyRouteWithChildren =
mainuserRouteLazyRoute._addFileChildren(mainuserRouteLazyRouteChildren)
interface mainRouteLazyRouteChildren {
mainuserRouteLazyRoute: typeof mainuserRouteLazyRouteWithChildren
mainPaymentLazyRoute: typeof mainPaymentLazyRoute
mainPrivacyPolicyLazyRoute: typeof mainPrivacyPolicyLazyRoute
mainTosLazyRoute: typeof mainTosLazyRoute
mainIndexLazyRoute: typeof mainIndexLazyRoute
mainPurchasingIndexLazyRoute: typeof mainPurchasingIndexLazyRoute
mainPurchasingOrderIndexRoute: typeof mainPurchasingOrderIndexRoute
}
const mainRouteLazyRouteChildren: mainRouteLazyRouteChildren = {
mainuserRouteLazyRoute: mainuserRouteLazyRouteWithChildren,
mainPaymentLazyRoute: mainPaymentLazyRoute,
mainPrivacyPolicyLazyRoute: mainPrivacyPolicyLazyRoute,
mainTosLazyRoute: mainTosLazyRoute,
mainIndexLazyRoute: mainIndexLazyRoute,
mainPurchasingIndexLazyRoute: mainPurchasingIndexLazyRoute,
mainPurchasingOrderIndexRoute: mainPurchasingOrderIndexRoute,
}
const mainRouteLazyRouteWithChildren = mainRouteLazyRoute._addFileChildren(
mainRouteLazyRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
mainRouteLazyRoute: mainRouteLazyRouteWithChildren,
BindPlatformLazyRoute: BindPlatformLazyRoute,
OauthPlatformLazyRoute: OauthPlatformLazyRoute,
AuthIndexLazyRoute: AuthIndexLazyRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Affiliate from "@/sections/user/affiliate";
export const Route = createLazyFileRoute("/(main)/(user)/affiliate")({
component: Affiliate,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Announcement from "@/sections/user/announcement/index";
export const Route = createLazyFileRoute("/(main)/(user)/announcement")({
component: Announcement,
});
@@ -0,0 +1,7 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Dashboard from "@/sections/user/dashboard";
export const Route = createLazyFileRoute("/(main)/(user)/dashboard")({
component: Dashboard,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Document from "@/sections/user/document";
export const Route = createLazyFileRoute("/(main)/(user)/document")({
component: Document,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Order from "@/sections/user/order";
export const Route = createLazyFileRoute("/(main)/(user)/order")({
component: Order,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Profile from "@/sections/user/profile";
export const Route = createLazyFileRoute("/(main)/(user)/profile")({
component: Profile,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import UserLayout from "@/sections/user/layout";
export const Route = createLazyFileRoute("/(main)/(user)")({
component: UserLayout,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Subscribe from "@/sections/subscribe";
export const Route = createLazyFileRoute("/(main)/(user)/subscribe")({
component: Subscribe,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Ticket from "@/sections/user/ticket";
export const Route = createLazyFileRoute("/(main)/(user)/ticket")({
component: Ticket,
});
@@ -0,0 +1,7 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Wallet from "@/sections/user/wallet";
export const Route = createLazyFileRoute("/(main)/(user)/wallet")({
component: Wallet,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Main from "@/sections/main";
export const Route = createLazyFileRoute("/(main)/")({
component: Main,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Payment from "@/sections/user/payment";
export const Route = createLazyFileRoute("/(main)/payment")({
component: Payment,
});
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Markdown } from "@workspace/ui/composed/markdown";
import { getPrivacyPolicy } from "@workspace/ui/services/common/common";
export const Route = createLazyFileRoute("/(main)/privacy-policy")({
component: () => {
const { data = "" } = useQuery({
queryKey: ["tos"],
queryFn: async () => {
const { data } = await getPrivacyPolicy();
return data.data?.privacy_policy;
},
});
return (
<div className="container py-8">
<Markdown>{data}</Markdown>
</div>
);
},
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Purchasing from "@/sections/purchasing";
export const Route = createLazyFileRoute("/(main)/purchasing/")({
component: Purchasing,
});
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Order from "@/sections/purchasing/order";
export const Route = createLazyFileRoute("/(main)/purchasing/order/")({
component: Order,
});
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchSchema = z.object({
order_no: z.string().optional(),
});
export const Route = createFileRoute("/(main)/purchasing/order/")({
validateSearch: searchSchema,
});
@@ -0,0 +1,13 @@
import { createLazyFileRoute, Outlet } from "@tanstack/react-router";
import Footer from "@/layout/footer";
import Header from "@/layout/header";
export const Route = createLazyFileRoute("/(main)")({
component: () => (
<>
<Header />
<Outlet />
<Footer />
</>
),
});
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { createLazyFileRoute } from "@tanstack/react-router";
import { Markdown } from "@workspace/ui/composed/markdown";
import { getTos } from "@workspace/ui/services/common/common";
export const Route = createLazyFileRoute("/(main)/tos")({
component: () => {
const { data = "" } = useQuery({
queryKey: ["tos"],
queryFn: async () => {
const { data } = await getTos();
return data.data?.tos_content;
},
});
return (
<div className="container py-8">
<Markdown>{data}</Markdown>
</div>
);
},
});
+79
View File
@@ -0,0 +1,79 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { Toaster } from "@workspace/ui/components/sonner";
import { NavigationProgress } from "@workspace/ui/composed/navigation-progress";
import { TanStackQueryDevtools } from "@workspace/ui/integrations/tanstack-query-devtools";
import { getCookie } from "@workspace/ui/lib/cookies";
import { getGlobalConfig } from "@workspace/ui/services/common/common";
import { isBrowser } from "@workspace/ui/utils/index";
import { useEffect } from "react";
import { Helmet, HelmetProvider } from "react-helmet-async";
import { useGlobalStore } from "@/stores/global";
export const Route = createRootRouteWithContext()({
component: () => {
const { common, setCommon, getUserInfo } = useGlobalStore();
useEffect(() => {
const initializeApp = async () => {
try {
const configResponse = await getGlobalConfig();
if (configResponse.data?.data) {
setCommon(configResponse.data.data);
}
try {
if (getCookie("Authorization")) {
await getUserInfo();
}
} catch {
/* empty */
}
} catch (error) {
console.error("Failed to initialize app:", error);
}
};
initializeApp();
}, []);
const { site } = common;
const title = site.site_name || "Loading...";
const description = site.site_desc || "";
const keywords = site.keywords || "";
const logo = site.site_logo || "";
const url = isBrowser() ? window.location.href : "";
return (
<HelmetProvider>
<Helmet>
<title>{title}</title>
<meta content={description} name="description" />
<meta content={keywords} name="keywords" />
<link href={url} rel="canonical" />
<link href={logo} rel="icon" type="image/svg+xml" />
<link href={logo} rel="apple-touch-icon" sizes="180x180" />
<link href="/site.webmanifest" rel="manifest" />
</Helmet>
<NavigationProgress />
<Outlet />
<Toaster closeButton richColors />
<div
dangerouslySetInnerHTML={{ __html: common?.site.custom_html || "" }}
id="custom_html"
/>
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
</HelmetProvider>
);
},
});
+6
View File
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import Auth from "@/sections/auth";
export const Route = createLazyFileRoute("/auth/")({
component: Auth,
});
@@ -0,0 +1,9 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import BindPage from "@/sections/bind";
export const Route = createLazyFileRoute("/bind/$platform")({
component: () => {
const { platform } = Route.useParams();
return <BindPage platform={platform} />;
},
});
@@ -0,0 +1,9 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import OAuthPage from "@/sections/oauth";
export const Route = createLazyFileRoute("/oauth/$platform")({
component: () => {
const { platform } = Route.useParams();
return <OAuthPage platform={platform} />;
},
});
@@ -0,0 +1,106 @@
"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 UserForm;
}
@@ -0,0 +1,142 @@
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 type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile 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.email(t("login.email", "Please enter a valid email address")),
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="Enter your email..."
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="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,248 @@
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 type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile 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", "Please enter a valid email address"))
.refine(handleCheckUser, {
message: t(
"register.whitelist",
"This email domain is not in the whitelist"
),
}),
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 currently disabled")}
</Markdown>
) : (
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Enter your email..."
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Enter your password..."
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeat_password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder="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="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",
"Invitation Code (Optional)"
)}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_register_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="register"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")}
</Button>
</form>
</Form>
)}
<div className="mt-4 text-right text-sm">
{t("register.existingAccount", "Already have an account?")}&nbsp;
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("login");
}}
variant="link"
>
{t("register.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
@@ -0,0 +1,167 @@
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 type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile 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", "Please enter a valid email address")),
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="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="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="Enter your new password..."
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="reset"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
</Button>
</form>
</Form>
<div className="mt-4 text-right text-sm">
{t("reset.existingAccount", "Remember your password?")}&nbsp;
<Button
className="p-0"
onClick={() => {
setInitialValues(undefined);
onSwitchForm("login");
}}
variant="link"
>
{t("reset.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { Link } from "@tanstack/react-router";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
import EmailAuthForm from "./email/auth-form";
import { OAuthMethods } from "./oauth-methods";
import PhoneAuthForm from "./phone/auth-form";
export default function Main() {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { site, auth } = common;
const AUTH_METHODS = [
{
key: "email",
enabled: auth.email.enable,
children: <EmailAuthForm />,
},
{
key: "mobile",
enabled: auth.mobile.enable,
children: <PhoneAuthForm />,
},
].filter((method) => method.enabled);
return (
<main className="flex h-full min-h-screen items-center bg-muted/50">
<div className="flex size-full flex-auto flex-col lg:flex-row">
<div className="flex bg-center bg-cover lg:w-1/2 lg:flex-auto">
<div className="flex w-full flex-col items-center justify-center px-5 py-7 md:px-15 lg:py-15">
<Link className="mb-0 flex flex-col items-center lg:mb-12" to="/">
{site.site_logo && (
<img alt="logo" height={48} src={site.site_logo} width={48} />
)}
<span className="font-semibold text-2xl">{site.site_name}</span>
</Link>
<DotLottieReact
autoplay
className="mx-auto hidden w-[275px] lg:block xl:w-[500px]"
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-12 lg:flex-auto lg:justify-end">
<div className="flex w-full flex-col items-center rounded-2xl md:w-[600px] md:p-10 lg:flex-auto lg:bg-background lg:shadow">
<div className="flex w-full flex-col items-stretch justify-center md:w-[400px] lg:h-full">
<div className="flex flex-col justify-center lg:flex-auto">
<h1 className="mb-3 text-center font-bold text-2xl">
{t("verifyAccount", "Verify Your Account")}
</h1>
<div className="mb-6 text-center font-medium text-muted-foreground">
{t(
"verifyAccountDesc",
"Please login or register to continue"
)}
</div>
{AUTH_METHODS.length === 1
? AUTH_METHODS[0]?.children
: AUTH_METHODS[0] && (
<Tabs defaultValue={AUTH_METHODS[0].key}>
<TabsList className="mb-6 flex w-full *:flex-1">
{AUTH_METHODS.map((item) => (
<TabsTrigger key={item.key} value={item.key}>
{t(`methods.${item.key}`)}
</TabsTrigger>
))}
</TabsList>
{AUTH_METHODS.map((item) => (
<TabsContent key={item.key} value={item.key}>
{item.children}
</TabsContent>
))}
</Tabs>
)}
</div>
<div className="py-8">
<OAuthMethods />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-5">
<LanguageSwitch />
<ThemeSwitch />
</div>
<div className="flex gap-2 font-semibold text-primary text-sm">
<Link to="/tos">{t("tos", "Terms of Service")}</Link>
<span className="text-foreground/30">|</span>
<Link to="/privacy-policy">
{t("privacyPolicy", "Privacy Policy")}
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import { Icon } from "@workspace/ui/composed/icon";
import { oAuthLogin } from "@workspace/ui/services/common/oauth";
import { useGlobalStore } from "@/stores/global";
const icons = {
apple: "uil:apple",
google: "logos:google-icon",
facebook: "logos:facebook",
github: "uil:github",
telegram: "logos:telegram",
};
export function OAuthMethods() {
const { common } = useGlobalStore();
const { oauth_methods } = common;
const OAUTH_METHODS = oauth_methods?.filter(
(method: string) => !["mobile", "email", "device"].includes(method)
);
return (
OAUTH_METHODS?.length > 0 && (
<>
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-border after:border-t">
<span className="relative z-10 bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
<div className="mt-6 flex justify-center gap-4 *:size-12 *:p-2">
{OAUTH_METHODS?.map((method: string) => (
<Button
asChild
key={method}
onClick={async () => {
const { data } = await oAuthLogin({
method,
redirect: `${window.location.origin}/oauth/${method}`,
});
if (data.data?.redirect) {
window.location.href = data.data?.redirect;
}
}}
size="icon"
variant="ghost"
>
<Icon icon={icons[method as keyof typeof icons]} />
</Button>
))}
</div>
</>
)
);
}
@@ -0,0 +1,105 @@
"use client";
import { useNavigate } from "@tanstack/react-router";
import {
telephoneLogin,
telephoneResetPassword,
telephoneUserRegister,
} 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 { 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 PhoneAuthForm() {
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<API.TelephoneLoginRequest>(
{
identifier: "",
telephone: "",
telephone_area_code: "1",
password: "",
telephone_code: "",
}
);
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 telephoneLogin(params);
toast.success(t("login.success", "Login successful!"));
onLogin(login.data.data?.token);
break;
}
case "register": {
const create = await telephoneUserRegister(params);
toast.success(t("register.success", "Registration successful!"));
onLogin(create.data.data?.token);
break;
}
case "reset":
await telephoneResetPassword(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}
/>
);
break;
case "register":
UserForm = (
<RegisterForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
/>
);
break;
case "reset":
UserForm = (
<ResetForm
initialValues={initialValues}
loading={loading}
onSubmit={handleFormSubmit}
onSwitchForm={setType}
setInitialValues={setInitialValues}
/>
);
break;
}
return UserForm;
}
@@ -0,0 +1,203 @@
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 { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
import { Icon } from "@workspace/ui/composed/icon";
import type { Dispatch, SetStateAction } from "react";
import { useRef, useState } 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 type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
export default function LoginForm({
loading,
onSubmit,
initialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify } = common;
const formSchema = z.object({
telephone_area_code: z.string(),
telephone: z.string(),
telephone_code: z.string().optional(),
password: z.string().optional(),
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 [mode, setMode] = useState<"password" | "code">("password");
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="telephone"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex">
<FormField
control={form.control}
name="telephone_area_code"
render={({ field }) => (
<FormItem>
<FormControl>
<AreaCodeSelect
className="w-32 rounded-r-none border-r-0"
onChange={(value) => {
if (value.phone) {
form.setValue(
"telephone_area_code",
value.phone
);
}
}}
placeholder="Area code..."
simple
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Input
className="rounded-l-none"
placeholder="Enter your telephone..."
type="tel"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={mode === "code" ? "telephone_code" : "password"}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder={
mode === "code" ? "Enter code..." : "Enter password..."
}
type={mode === "code" ? "text" : "password"}
{...field}
/>
{mode === "code" && (
<SendCode
params={{
...form.getValues(),
type: 2,
}}
type="phone"
/>
)}
</div>
</FormControl>
<div className="!mt-0 text-right">
<Button
className="px-0 text-primary text-sm"
onClick={(e) => {
e.preventDefault();
setMode(mode === "password" ? "code" : "password");
}}
variant="link"
>
{mode === "password"
? t("login.codeLogin", "Login with Code")
: t("login.passwordLogin", "Login with Password")}
</Button>
</div>
<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,256 @@
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 { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
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 type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
export default function RegisterForm({
loading,
onSubmit,
initialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
onSwitchForm: Dispatch<SetStateAction<"register" | "reset" | "login">>;
}) {
const { t } = useTranslation("auth");
const { common } = useGlobalStore();
const { verify, auth, invite } = common;
const { enable_whitelist, whitelist } = auth.mobile;
const formSchema = z
.object({
telephone_area_code: z.string(),
telephone: z.string(),
password: z.string(),
repeat_password: z.string(),
code: z.string(),
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,
telephone_area_code: initialValues?.telephone_area_code || "1",
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 currently disabled")}
</Markdown>
) : (
<Form {...form}>
<form className="grid gap-6" onSubmit={handleSubmit}>
<FormField
control={form.control}
name="telephone"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex">
<FormField
control={form.control}
name="telephone_area_code"
render={({ field }) => (
<FormItem>
<FormControl>
<AreaCodeSelect
className="w-32 rounded-r-none border-r-0"
onChange={(value) => {
if (value.phone) {
form.setValue(
"telephone_area_code",
value.phone
);
}
}}
placeholder="Area code..."
simple
value={field.value}
whitelist={enable_whitelist ? whitelist : []}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Input
className="rounded-l-none"
placeholder="Enter your telephone..."
type="tel"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Enter your password..."
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeat_password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder="Enter password again..."
type="password"
{...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="Enter code..."
type="text"
{...field}
value={field.value as string}
/>
<SendCode
params={{
...form.getValues(),
type: 1,
}}
type="phone"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="invite"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading || !!localStorage.getItem("invite")}
placeholder={t(
"register.invite",
"Invitation Code (Optional)"
)}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_register_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="register"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")}
</Button>
</form>
</Form>
)}
<div className="mt-4 text-right text-sm">
{t("register.existingAccount", "Already have an account?")}&nbsp;
<Button
className="p-0"
onClick={() => {
// setInitialValues(undefined);
onSwitchForm("login");
}}
variant="link"
>
{t("register.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
@@ -0,0 +1,192 @@
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 { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
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 type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
export default function ResetForm({
loading,
onSubmit,
initialValues,
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({
telephone_area_code: z.string(),
telephone: z.string(),
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="telephone"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex">
<FormField
control={form.control}
name="telephone_area_code"
render={({ field }) => (
<FormItem>
<FormControl>
<AreaCodeSelect
className="w-32 rounded-r-none border-r-0"
onChange={(value) => {
if (value.phone) {
form.setValue(
"telephone_area_code",
value.phone
);
}
}}
placeholder="Area code..."
simple
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Input
className="rounded-l-none"
placeholder="Enter your telephone..."
type="tel"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Input
placeholder="Enter code..."
type="text"
{...field}
value={field.value as string}
/>
<SendCode
params={{
...form.getValues(),
type: 2,
}}
type="phone"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Enter your new password..."
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{verify.enable_reset_password_verify && (
<FormField
control={form.control}
name="cf_token"
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile
id="reset"
{...field}
ref={turnstile}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
</Button>
</form>
</Form>
<div className="mt-4 text-right text-sm">
{t("reset.existingAccount", "Remember your password?")}&nbsp;
<Button
className="p-0"
onClick={() => {
onSwitchForm("login");
}}
variant="link"
>
{t("reset.switchToLogin", "Login")}
</Button>
</div>
</>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
sendEmailCode,
sendSmsCode,
} from "@workspace/ui/services/common/common";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
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 { common } = useGlobalStore();
const { verify_code_interval } = common.verify_code;
const [targetDate, setTargetDate] = useState<number>();
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const storedEndTime = localStorage.getItem(`verify_code_${type}`);
if (storedEndTime) {
const endTime = Number.parseInt(storedEndTime, 10);
if (endTime > Date.now()) {
setTargetDate(endTime);
} else {
localStorage.removeItem(`verify_code_${type}`);
}
}
}, [type]);
useEffect(() => {
if (!targetDate) {
setSeconds(0);
return;
}
const updateTimer = () => {
const now = Date.now();
const remaining = Math.max(0, Math.ceil((targetDate - now) / 1000));
setSeconds(remaining);
if (remaining === 0) {
setTargetDate(undefined);
localStorage.removeItem(`verify_code_${type}`);
}
};
updateTimer();
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [targetDate, type]);
const setCodeTimer = () => {
const endTime = Date.now() + verify_code_interval * 1000;
setTargetDate(endTime);
localStorage.setItem(`verify_code_${type}`, endTime.toString());
};
const getEmailCode = async () => {
if (params.email && params.type) {
await sendEmailCode({
email: params.email,
type: params.type,
});
setCodeTimer();
}
};
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,
});
setCodeTimer();
}
};
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>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
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)}
// onError={() => {
// onChange();
// turnstile.reset();
// }}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
)
);
});
export default CloudFlareTurnstile;
@@ -0,0 +1,33 @@
"use client";
import { useRouter, useSearch } from "@tanstack/react-router";
import { bindOAuthCallback } from "@workspace/ui/services/user/user";
import { useEffect } from "react";
interface CertificationProps {
platform: string;
children: React.ReactNode;
}
export default function Certification({
platform,
children,
}: CertificationProps) {
const router = useRouter();
const searchParams = useSearch({ strict: false });
useEffect(() => {
bindOAuthCallback({
method: platform,
callback: searchParams as Record<string, string>,
})
.then(() => {
router.navigate({ to: "/profile" });
})
.catch(() => {
router.navigate({ to: "/auth" });
});
}, [platform, router, searchParams]);
return children;
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Spinner } from "@workspace/ui/components/spinner";
import { Icon } from "@workspace/ui/composed/icon";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Certification from "./certification";
export default function BindPage({
platform,
children,
}: {
platform: string;
children?: ReactNode;
}) {
const { t } = useTranslation("auth");
return (
<Certification platform={platform}>
<div className="relative flex h-screen w-full flex-col items-center justify-center overflow-hidden bg-background">
<div className="flex animate-pulse flex-col items-center gap-4">
<div className="flex items-center gap-3">
<Icon className="size-12" icon={`logos:${platform}`} />
<Spinner className="size-8" />
</div>
<div className="flex flex-col items-center gap-2">
<h1 className="bg-gradient-to-r from-blue-500 via-indigo-500 to-violet-500 bg-clip-text font-black text-transparent text-xl uppercase md:text-2xl dark:from-blue-400 dark:via-indigo-300 dark:to-violet-400">
{platform}
</h1>
<p className="text-lg text-muted-foreground md:text-xl">
{t("binding", "Binding account...")}
</p>
</div>
</div>
{children}
</div>
</Certification>
);
}
@@ -0,0 +1,53 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
export function GlobalMap() {
const { t } = useTranslation("main");
return (
<motion.section
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
whileInView={{ opacity: 1 }}
>
<motion.h2
className="mb-2 text-center font-bold text-3xl"
initial={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5 }}
whileInView={{ opacity: 1, y: 0 }}
>
{t("global_map_itle", "Global Connection, Easy and Worry-free")}
</motion.h2>
<motion.p
className="mb-8 text-center text-lg text-muted-foreground"
initial={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5, delay: 0.1 }}
whileInView={{ opacity: 1, y: 0 }}
>
{t(
"global_map_description",
"Explore seamless global connectivity. Choose network services that suit your needs and stay connected anytime, anywhere."
)}
</motion.p>
<motion.div
animate={{ scale: 1, opacity: 1 }}
className="aspect-video w-full overflow-hidden"
initial={{ scale: 0.9, opacity: 0 }}
transition={{
type: "spring",
stiffness: 100,
damping: 15,
delay: 0.4,
}}
>
<DotLottieReact
autoplay
className="w-full scale-150"
loop
src="/lotties/global-map.json"
/>
</motion.div>
</motion.section>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { Link } from "@tanstack/react-router";
import { HoverBorderGradient } from "@workspace/ui/components/hover-border-gradient";
import { TextGenerateEffect } from "@workspace/ui/components/text-generate-effect";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
export function Hero() {
const { t } = useTranslation("main");
const { common, user } = useGlobalStore();
const { site } = common;
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="grid gap-8 pt-16 sm:grid-cols-2"
initial={{ opacity: 0, y: -50 }}
transition={{ type: "spring", stiffness: 100, damping: 20 }}
viewport={{ once: true, amount: 0.2 }}
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-start justify-center"
initial={{ opacity: 0, y: 50 }}
transition={{ type: "spring", stiffness: 80, damping: 15, delay: 0.3 }}
viewport={{ once: true, amount: 0.3 }}
>
<h1 className="my-6 font-bold text-4xl lg:text-6xl">
{t("welcome", "Welcome to")} {site.site_name}
</h1>
{site.site_desc && (
<TextGenerateEffect
className="mb-8 max-w-xl *:text-muted-foreground"
words={site.site_desc}
/>
)}
<Link to={user ? "/dashboard" : "/auth"}>
<HoverBorderGradient
as="button"
className="m-0.5 flex items-center space-x-2 text-white"
containerClassName="rounded-full"
>
{t("started", "Get Started")}
</HoverBorderGradient>
</Link>
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex w-full"
initial={{ opacity: 0, y: 50 }}
transition={{ type: "spring", stiffness: 80, damping: 15, delay: 0.5 }}
viewport={{ once: true, amount: 0.3 }}
>
<DotLottieReact autoplay loop src="/lotties/network-security.json" />
</motion.div>
</motion.div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useGlobalStore } from "@/stores/global";
import { GlobalMap } from "./global-map";
import { Hero } from "./hero";
import { ProductShowcase } from "./product-showcase";
import { Stats } from "./stats";
export default function Main() {
const { user } = useGlobalStore();
const navigate = useNavigate();
useEffect(() => {
if (user) {
navigate({ to: "/dashboard" });
}
}, [user, navigate]);
return (
<main className="container space-y-16">
<Hero />
<Stats />
<ProductShowcase />
<GlobalMap />
</main>
);
}
@@ -0,0 +1,178 @@
import { Link } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@workspace/ui/components/card";
import { Separator } from "@workspace/ui/components/separator";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { motion } from "framer-motion";
import type { Key, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { SubscribeDetail } from "@/sections/subscribe/detail";
import { useGlobalStore } from "@/stores/global";
interface ProductShowcaseProps {
subscriptionData: API.Subscribe[];
}
export function Content({ subscriptionData }: ProductShowcaseProps) {
const { t } = useTranslation("main");
const { user } = useGlobalStore();
const unitTimeMap: Record<string, string> = {
Day: t("Day", "Day"),
Hour: t("Hour", "Hour"),
Minute: t("Minute", "Minute"),
Month: t("Month", "Month"),
NoLimit: t("NoLimit", "No Limit"),
Year: t("Year", "Year"),
};
return (
<motion.section
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
whileInView={{ opacity: 1 }}
>
<motion.h2
className="mb-2 text-center font-bold text-3xl"
initial={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5 }}
whileInView={{ opacity: 1, y: 0 }}
>
{t("product_showcase_title", "Choose Your Package")}
</motion.h2>
<motion.p
className="mb-8 text-center text-lg text-muted-foreground"
initial={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5, delay: 0.1 }}
whileInView={{ opacity: 1, y: 0 }}
>
{t(
"product_showcase_description",
"Let us help you select the package that best suits you and enjoy exploring it."
)}
</motion.p>
<div className="mx-auto flex flex-wrap justify-center gap-8 overflow-x-auto overflow-y-hidden *:max-w-80 *:flex-auto">
{subscriptionData?.map((item, index) => (
<motion.div
className="w-1/2 lg:w-1/4"
initial={{ opacity: 0, y: 50 }}
key={item.id}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true, amount: 0.5 }}
whileInView={{ opacity: 1, y: 0 }}
>
<Card className="flex flex-col gap-0 overflow-hidden rounded-lg py-0 shadow-lg transition-shadow duration-300 hover:shadow-2xl">
<CardHeader className="bg-muted/50 p-4 font-medium text-xl">
{item.name}
</CardHeader>
<CardContent className="flex flex-grow flex-col gap-4 p-6 text-sm">
<ul className="flex flex-grow flex-col gap-3">
{(() => {
let parsedDescription: {
description: string;
features: Array<{
icon: string;
label: ReactNode;
type: "default" | "success" | "destructive";
}>;
};
try {
parsedDescription = JSON.parse(item.description);
} catch {
parsedDescription = { description: "", features: [] };
}
const { description, features } = parsedDescription;
return (
<>
{description && (
<li className="text-muted-foreground">
{description}
</li>
)}
{features?.map(
(
feature: {
type: string;
icon: string;
label: ReactNode;
},
index: Key
) => (
<li
className={cn("flex items-center gap-2", {
"text-muted-foreground line-through":
feature.type === "destructive",
})}
key={index}
>
{feature.icon && (
<Icon
className={cn("size-5 text-primary", {
"text-green-500":
feature.type === "success",
"text-destructive":
feature.type === "destructive",
})}
icon={feature.icon}
/>
)}
{feature.label}
</li>
)
)}
</>
);
})()}
</ul>
<SubscribeDetail
subscribe={{
...item,
name: undefined,
}}
/>
</CardContent>
<Separator />
<CardFooter className="relative flex flex-col gap-4 p-4">
<motion.h2
animate={{ opacity: 1 }}
className="pb-4 font-semibold text-2xl sm:text-3xl"
initial={{ opacity: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Display type="currency" value={item.unit_price} />
<span className="font-medium text-base">
/
{unitTimeMap[item.unit_time!] ||
t(item.unit_time || "Month", item.unit_time || "Month")}
</span>
</motion.h2>
<motion.div>
<Button
asChild
className="absolute bottom-0 left-0 w-full rounded-t-none rounded-b-xl"
>
<Link
search={user ? undefined : { id: item.id }}
to={user ? "/subscribe" : "/purchasing"}
>
{t("subscribe", "Subscribe")}
</Link>
</Button>
</motion.div>
</CardFooter>
</Card>
</motion.div>
))}
</div>
</motion.section>
);
}
@@ -0,0 +1,36 @@
import { getSubscription } from "@workspace/ui/services/user/portal";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Content } from "./content";
export function ProductShowcase() {
const { i18n } = useTranslation();
const [subscriptionList, setSubscriptionList] = useState<API.Subscribe[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchSubscriptions = async () => {
try {
const { data } = await getSubscription(
{
language: i18n.language,
},
{
skipErrorHandler: true,
}
);
setSubscriptionList(data.data?.list || []);
} catch (error) {
console.error("Failed to fetch subscriptions:", error);
} finally {
setIsLoading(false);
}
};
fetchSubscriptions();
}, [i18n.language]);
if (isLoading || subscriptionList.length === 0) return null;
return <Content subscriptionData={subscriptionList} />;
}
+82
View File
@@ -0,0 +1,82 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
export function Stats() {
const { t } = useTranslation("main");
const list = [
{
name: t("users", "Users"),
description: t("users_description", "Trusted by users worldwide"),
icon: (
<DotLottieReact
autoplay
className="size-24"
loop
src="/lotties/users.json"
/>
),
},
{
name: t("servers", "Servers"),
description: t(
"servers_description",
"High-performance servers globally"
),
icon: (
<DotLottieReact
autoplay
className="size-24"
loop
src="/lotties/servers.json"
/>
),
},
{
name: t("locations", "Locations"),
description: t("locations_description", "Available in multiple regions"),
icon: (
<DotLottieReact
autoplay
className="size-24"
loop
src="/lotties/locations.json"
/>
),
},
];
return (
<motion.section
animate={{ opacity: 1, y: 0 }}
className="z-10 grid w-full grid-cols-1 divide-y-2 divide-muted rounded-lg sm:grid-cols-3 sm:divide-x-2 sm:divide-y-0"
initial={{ opacity: 0, y: 50 }}
transition={{ duration: 1, ease: "easeOut" }}
viewport={{ once: true, amount: 0.8 }}
whileInView={{ opacity: 1, y: 0 }}
>
{list.map((item, index) => (
<motion.div
className="mx-auto flex w-10/12 items-center justify-start px-4 py-4 sm:w-full sm:justify-center sm:py-6"
initial={{ opacity: 0, scale: 0.8 }}
key={item.name}
transition={{ duration: 0.8, delay: index * 0.3, ease: "easeOut" }}
viewport={{ once: true, amount: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
>
<div className="flex w-full items-center sm:w-auto">
<div className="mr-4 flex h-20 w-20 items-center justify-center rounded-full">
{item.icon}
</div>
<div className="flex flex-col">
<p className="font-semibold text-lg">{item.name}</p>
<p className="text-muted-foreground text-sm">
{item.description}
</p>
</div>
</div>
</motion.div>
))}
</motion.section>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useRouter, useSearch } from "@tanstack/react-router";
import { oAuthLoginGetToken } from "@workspace/ui/services/common/oauth";
import { useEffect } from "react";
import { getRedirectUrl, setAuthorization } from "@/utils/common";
interface CertificationProps {
platform: string;
children: React.ReactNode;
}
export default function Certification({
platform,
children,
}: CertificationProps) {
const router = useRouter();
const searchParams = useSearch({ strict: false });
useEffect(() => {
const inviteCode = localStorage.getItem("invite") || "";
oAuthLoginGetToken({
method: platform,
callback: searchParams as Record<string, string>,
...(inviteCode && { invite: inviteCode }),
} as API.OAuthLoginGetTokenRequest)
.then((res) => {
const token = res?.data?.data?.token;
if (!token) {
throw new Error("Invalid token");
}
setAuthorization(token);
router.navigate({ to: getRedirectUrl() });
})
.catch(() => {
router.navigate({ to: "/auth" });
});
}, [platform, router, searchParams]);
return children;
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Spinner } from "@workspace/ui/components/spinner";
import { Icon } from "@workspace/ui/composed/icon";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Certification from "./certification";
export default function OAuthPage({
platform,
children,
}: {
platform: string;
children?: ReactNode;
}) {
const { t } = useTranslation("auth");
return (
<Certification platform={platform}>
<div className="relative flex h-screen w-full flex-col items-center justify-center overflow-hidden bg-background">
<div className="flex animate-pulse flex-col items-center gap-4">
<div className="flex items-center gap-3">
<Icon className="size-12" icon={`logos:${platform}`} />
<Spinner className="size-8" />
</div>
<div className="flex flex-col items-center gap-2">
<h1 className="bg-gradient-to-r from-blue-500 via-indigo-500 to-violet-500 bg-clip-text font-black text-transparent text-xl uppercase md:text-2xl dark:from-blue-400 dark:via-indigo-300 dark:to-violet-400">
{platform}
</h1>
<p className="text-lg text-muted-foreground md:text-xl">
{t("authenticating", "Authenticating...")}
</p>
</div>
</div>
{children}
</div>
</Certification>
);
}
@@ -0,0 +1,328 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent, CardHeader } from "@workspace/ui/components/card";
import { Separator } from "@workspace/ui/components/separator";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { prePurchaseOrder, purchase } from "@workspace/ui/services/user/portal";
import { LoaderCircle } from "lucide-react";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { SubscribeBilling } from "@/sections/subscribe/billing";
import CouponInput from "@/sections/subscribe/coupon-input";
import { SubscribeDetail } from "@/sections/subscribe/detail";
import DurationSelector from "@/sections/subscribe/duration-selector";
import PaymentMethods from "@/sections/subscribe/payment-methods";
import { useGlobalStore } from "@/stores/global";
export default function Content({
subscription,
}: {
subscription?: API.Subscribe;
}) {
const { t } = useTranslation("subscribe");
const unitTimeMap: Record<string, string> = {
Day: t("Day", "Day"),
Hour: t("Hour", "Hour"),
Minute: t("Minute", "Minute"),
Month: t("Month", "Month"),
NoLimit: t("NoLimit", "No Limit"),
Year: t("Year", "Year"),
};
const { common } = useGlobalStore();
const navigate = useNavigate();
const [params, setParams] = useState<API.PortalPurchaseRequest>({
quantity: 1,
subscribe_id: 0,
payment: -1,
coupon: "",
auth_type: "email",
identifier: "",
password: "",
});
const [loading, startTransition] = useTransition();
const [isEmailValid, setIsEmailValid] = useState({
valid: false,
message: "",
});
const { data: order } = useQuery({
enabled: !!subscription?.id && !!params.payment,
queryKey: [
"preCreateOrder",
params.coupon,
params.quantity,
params.payment,
],
queryFn: async () => {
const { data } = await prePurchaseOrder({
...params,
subscribe_id: subscription?.id as number,
} as API.PrePurchaseOrderRequest);
return data.data;
},
});
useEffect(() => {
if (subscription) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscription?.id,
}));
}
}, [subscription]);
const handleChange = useCallback(
(field: keyof typeof params, value: string | number) => {
setParams((prev) => ({
...prev,
[field]: value,
}));
},
[]
);
const handleSubmit = useCallback(async () => {
startTransition(async () => {
try {
const { data } = await purchase(params);
const { order_no } = data.data!;
if (order_no) {
localStorage.setItem(
order_no,
JSON.stringify({
auth_type: params.auth_type,
identifier: params.identifier,
})
);
navigate({ to: "/purchasing/order", search: { order_no } });
}
} catch (error) {
console.log(error);
}
});
}, [params, navigate]);
if (!subscription) {
return (
<div className="p-6 text-center">
{t("subscriptionNotFound", "Subscription not found")}
</div>
);
}
return (
<div className="mx-auto mt-8 flex max-w-4xl flex-col gap-8 md:grid md:grid-cols-2 md:flex-row">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
{t(
"emailInputTitle",
"Enter the email address for your {{siteName}} account",
{
siteName: common.site.site_name,
}
)}
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<EnhancedInput
className={cn({
"border-destructive":
!isEmailValid.valid && params.identifier !== "",
})}
onValueChange={(value: string) => {
const email = value as string;
setParams((prev) => ({
...prev,
identifier: email,
}));
const reg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!reg.test(email)) {
setIsEmailValid({
valid: false,
message: t(
"invalidEmail",
"Please enter a valid email address"
),
});
} else if (common.auth.email.enable_domain_suffix) {
const domain = email.split("@")[1];
const isValid = common.auth.email?.domain_suffix_list
.split("\n")
.includes(domain || "");
if (!isValid) {
setIsEmailValid({
valid: false,
message: t(
"emailDomainNotAllowed",
"Email domain is not in the whitelist"
),
});
return;
}
} else {
setIsEmailValid({
valid: true,
message: "",
});
}
}}
placeholder="Email"
required
type="email"
value={params.identifier || ""}
/>
<p
className={cn("text-muted-foreground text-xs", {
"text-destructive":
!isEmailValid.valid && params.identifier !== "",
})}
>
{isEmailValid.message ||
t("emailRequired", "Please enter your email address.")}
</p>
</div>
{params.identifier && isEmailValid.valid && (
<div className="flex flex-col gap-2">
<EnhancedInput
onValueChange={(value: string) =>
handleChange("password", value)
}
placeholder="Password"
type="password"
value={params.password || ""}
/>
<p className="text-muted-foreground text-xs">
{t(
"passwordHint",
"If you do not enter a password, we will automatically generate one and send it to your email."
)}
</p>
</div>
)}
{/* <div>
<OAuthMethods />
</div> */}
</CardContent>
</Card>
<Card>
<CardContent className="grid gap-3 text-sm">
<h2 className="font-semibold text-xl">{subscription.name}</h2>
<ul className="flex flex-grow flex-col gap-3">
{(() => {
let parsedDescription: {
description: string;
features: Array<{
icon: string;
label: string;
type: "default" | "success" | "destructive";
}>;
};
try {
parsedDescription = JSON.parse(subscription.description);
} catch {
parsedDescription = { description: "", features: [] };
}
const { description, features } = parsedDescription;
return (
<>
{description && (
<li className="text-muted-foreground">{description}</li>
)}
{features?.map(
(
feature: {
icon: string;
label: string;
type: "default" | "success" | "destructive";
},
index: number
) => (
<li
className={cn("flex items-center gap-1", {
"text-muted-foreground line-through":
feature.type === "destructive",
})}
key={index}
>
{feature.icon && (
<Icon
className={cn("size-5 text-primary", {
"text-green-500": feature.type === "success",
"text-destructive":
feature.type === "destructive",
})}
icon={feature.icon}
/>
)}
{feature.label}
</li>
)
)}
</>
);
})()}
</ul>
<SubscribeDetail
subscribe={{
...subscription,
quantity: params.quantity,
}}
/>
<Separator />
<SubscribeBilling
order={{
...order,
quantity: params.quantity,
unit_price: subscription?.unit_price,
}}
/>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-6">
<Card>
<CardContent className="p-6">
<div className="grid gap-6">
<DurationSelector
discounts={subscription?.discount}
onChange={(value: number) => handleChange("quantity", value)}
quantity={params.quantity!}
unitTime={
unitTimeMap[subscription.unit_time!] || subscription.unit_time
}
/>
<CouponInput
coupon={params.coupon}
onChange={(value: string) => handleChange("coupon", value)}
/>
<PaymentMethods
balance={false}
onChange={(value: number) => handleChange("payment", value)}
value={params.payment!}
/>
</div>
</CardContent>
</Card>
<Button
className="w-full"
disabled={!isEmailValid.valid || loading}
onClick={handleSubmit}
size="lg"
>
{loading && <LoaderCircle className="mr-2 animate-spin" />}
{t("buyNow", "Buy Now")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useQuery } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import { getSubscription } from "@workspace/ui/services/user/portal";
import { useTranslation } from "react-i18next";
import Content from "./content";
export default function Purchasing() {
const { id } = useSearch({ from: "/(main)/purchasing/" }) as { id: string };
const { i18n } = useTranslation();
const { data } = useQuery({
queryKey: ["subscription", i18n.language],
queryFn: async () => {
const { data } = await getSubscription(
{
language: i18n.language,
},
{
skipErrorHandler: true,
}
);
return data.data?.list || [];
},
});
const subscription = data?.find(
(item: API.Subscribe) => item.id === Number(id)
);
return (
<main className="container space-y-16">
<Content subscription={subscription} />
</main>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Link, useSearch } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Separator } from "@workspace/ui/components/separator";
import { Icon } from "@workspace/ui/composed/icon";
import {
purchaseCheckout,
queryPurchaseOrder,
} from "@workspace/ui/services/user/portal";
import { formatDate } from "@workspace/ui/utils/formatting";
import { useCountDown } from "ahooks";
import { addMinutes, format } from "date-fns";
import { QRCodeCanvas } from "qrcode.react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { SubscribeBilling } from "@/sections/subscribe/billing";
import { SubscribeDetail } from "@/sections/subscribe/detail";
import StripePayment from "@/sections/user/payment/stripe";
import { useGlobalStore } from "@/stores/global";
import { setAuthorization } from "@/utils/common";
export default function Order() {
const { t } = useTranslation("order");
const { getUserInfo } = useGlobalStore();
const [orderNo, setOrderNo] = useState<string>();
const [enabled, setEnabled] = useState<boolean>(false);
const search = useSearch({ from: "/(main)/purchasing/order/" });
const { data } = useQuery({
enabled,
queryKey: ["queryPurchaseOrder", orderNo],
queryFn: async () => {
if (!orderNo) return;
const params = localStorage.getItem(orderNo);
const authParams = params ? JSON.parse(params) : {};
const { data } = await queryPurchaseOrder({
order_no: orderNo,
...authParams,
});
if (data?.data?.status !== 1) {
setEnabled(false);
if (data?.data?.token) {
setAuthorization(data?.data?.token);
await new Promise((resolve) => setTimeout(resolve, 100));
await getUserInfo();
}
}
return data?.data;
},
refetchInterval: 3000,
});
const { data: payment } = useQuery({
enabled: !!orderNo && data?.status === 1,
queryKey: ["purchaseCheckout", orderNo],
queryFn: async () => {
const { data } = await purchaseCheckout({
orderNo: orderNo || "",
returnUrl: window.location.href,
});
if (data.data?.type === "url" && data.data?.checkout_url) {
window.open(data.data.checkout_url, "_blank");
}
return data?.data;
},
});
useEffect(() => {
if (search.order_no) {
setOrderNo(search.order_no);
setEnabled(true);
}
}, [search]);
const [countDown, formattedRes] = useCountDown({
targetDate:
data &&
format(addMinutes(data?.created_at, 15), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
});
const { hours, minutes, seconds } = formattedRes;
const countdownDisplay =
countDown > 0 ? (
<>
{hours.toString().length === 1 ? `0${hours}` : hours} :{" "}
{minutes.toString().length === 1 ? `0${minutes}` : minutes} :{" "}
{seconds.toString().length === 1 ? `0${seconds}` : seconds}
</>
) : (
t("timeExpired", "Time Expired")
);
return (
<main className="container lg:mt-16">
<div className="grid gap-4 xl:grid-cols-2">
<Card className="order-2 xl:order-1">
<CardHeader className="flex flex-row items-start bg-muted/50">
<div className="grid gap-0.5">
<CardTitle className="flex flex-col text-lg">
{t("orderNumber", "Order Number")}
<span>{data?.order_no}</span>
</CardTitle>
<CardDescription>
{t("createdAt", "Created At")}: {formatDate(data?.created_at)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="grid gap-3 p-6 text-sm">
<div className="font-semibold">
{t("paymentMethod", "Payment Method")}
</div>
<dl className="grid gap-3">
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">
<Badge>{data?.payment.name || data?.payment.platform}</Badge>
</dt>
</div>
</dl>
<Separator />
{data?.status && [1, 2].includes(data.status) && (
<SubscribeDetail
subscribe={{
...data?.subscribe,
quantity: data?.quantity,
}}
/>
)}
{data?.status === 3 && (
<>
<div className="font-semibold">
{t("resetTraffic", "Reset Traffic")}
</div>
<ul className="grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
<li className="flex items-center justify-between">
<span className="line-clamp-2 flex-1 text-muted-foreground">
{t("resetPrice", "Reset Price")}
</span>
<span>
<Display type="currency" value={data.amount} />
</span>
</li>
</ul>
</>
)}
{data?.status === 4 && (
<>
<div className="font-semibold">
{t("balanceRecharge", "Balance Recharge")}
</div>
<ul className="grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
<li className="flex items-center justify-between">
<span className="line-clamp-2 flex-1 text-muted-foreground">
{t("rechargeAmount", "Recharge Amount")}
</span>
<span>
<Display type="currency" value={data.amount} />
</span>
</li>
</ul>
</>
)}
<Separator />
<SubscribeBilling
order={{
...data,
unit_price: data?.subscribe?.unit_price,
}}
/>
</CardContent>
</Card>
<Card className="order-1 flex flex-auto items-center justify-center xl:order-2">
<CardContent className="py-16">
{data?.status && [2, 5].includes(data?.status) && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("paymentSuccess", "Payment Successful")}
</h3>
<Icon
className="text-7xl text-green-500"
icon="mdi:success-circle-outline"
/>
<div className="flex gap-4">
<Button asChild>
<Link to="/dashboard">
{t("subscribeNow", "Subscribe Now")}
</Link>
</Button>
<Button variant="outline">
<Link to="/document">
{t("viewDocument", "View Document")}
</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === "url" && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("waitingForPayment", "Waiting for Payment")}
</h3>
<p className="flex items-center font-bold text-3xl">
{countdownDisplay}
</p>
<Icon
className="text-7xl text-muted-foreground"
icon="mdi:access-time"
/>
<div className="flex gap-4">
<Button
onClick={() => {
if (payment?.checkout_url) {
window.location.href = payment?.checkout_url;
}
}}
>
{t("goToPayment", "Go to Payment")}
</Button>
<Button variant="outline">
<Link search={{ id: 0 }} to="/">
{t("productList", "Product List")}
</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === "qr" && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("scanToPay", "Scan to Pay")}
</h3>
<p className="flex items-center font-bold text-3xl">
{countdownDisplay}
</p>
<QRCodeCanvas
imageSettings={{
src: "/payment/alipay_f2f.svg",
width: 24,
height: 24,
excavate: true,
}}
size={208}
value={payment?.checkout_url || ""}
/>
<div className="flex gap-4">
<Button asChild>
<Link to="/subscribe">
{t("productList", "Product List")}
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/order">{t("orderList", "Order List")}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === "stripe" && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("waitingForPayment", "Waiting for Payment")}
</h3>
<p className="flex items-center font-bold text-3xl">
{countdownDisplay}
</p>
{payment.stripe && <StripePayment {...payment.stripe} />}
{/* <div className='flex gap-4'>
<Button asChild>
<Link to='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link to='/order'>{t('orderList')}</Link>
</Button>
</div> */}
</div>
)}
{data?.status && [3, 4].includes(data?.status) && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("orderClosed", "Order Closed")}
</h3>
<Icon className="text-7xl text-red-500" icon="mdi:cancel" />
<div className="flex gap-4">
<Button asChild>
<Link to="/subscribe">
{t("productList", "Product List")}
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/order">{t("orderList", "Order List")}</Link>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</main>
);
}
@@ -0,0 +1,92 @@
"use client";
import { Separator } from "@workspace/ui/components/separator";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
interface SubscribeBillingProps {
order?: Partial<
API.OrderDetail & {
unit_price: number;
unit_time: string;
subscribe_discount: number;
}
>;
}
export function SubscribeBilling({ order }: Readonly<SubscribeBillingProps>) {
const { t } = useTranslation("subscribe");
return (
<>
<div className="font-semibold">
{t("billing.billingTitle", "Billing Detail")}
</div>
<ul className="grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
{order?.type && [1, 2].includes(order?.type) && (
<li>
<span className="text-muted-foreground">
{t("billing.duration", "Duration")}
</span>
<span>
{order?.quantity || 1}{" "}
{t(order?.unit_time || "Month", order?.unit_time || "Month")}
</span>
</li>
)}
<li>
<span className="text-muted-foreground">
{t("billing.price", "Price")}
</span>
<span>
<Display
type="currency"
value={order?.price || order?.unit_price}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("billing.productDiscount", "Product Discount")}
</span>
<span>
<Display type="currency" value={order?.discount} />
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("billing.couponDiscount", "Coupon Discount")}
</span>
<span>
<Display type="currency" value={order?.coupon_discount} />
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("billing.fee", "Fee")}
</span>
<span>
<Display type="currency" value={order?.fee_amount} />
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("billing.gift", "Gift")}
</span>
<span>
<Display type="currency" value={order?.gift_amount} />
</span>
</li>
</ul>
<Separator />
<div className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">
{t("billing.total", "Total")}
</span>
<span>
<Display type="currency" value={order?.amount} />
</span>
</div>
</>
);
}
@@ -0,0 +1,27 @@
"use client";
import { Input } from "@workspace/ui/components/input";
import type React from "react";
import { useTranslation } from "react-i18next";
interface CouponInputProps {
coupon?: string;
onChange: (value: string) => void;
}
const CouponInput: React.FC<CouponInputProps> = ({ coupon, onChange }) => {
const { t } = useTranslation("subscribe");
return (
<>
<div className="font-semibold">{t("coupon", "Coupon")}</div>
<Input
onChange={(e) => onChange(e.target.value.trim())}
placeholder={t("enterCoupon", "Enter Coupon")}
value={coupon}
/>
</>
);
};
export default CouponInput;
@@ -0,0 +1,63 @@
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
interface SubscribeDetailProps {
subscribe?: Partial<
API.Subscribe & {
name: string;
quantity: number;
}
>;
}
export function SubscribeDetail({ subscribe }: Readonly<SubscribeDetailProps>) {
const { t } = useTranslation("subscribe");
return (
<>
<div className="font-semibold">
{t("detail.productDetail", "Product Detail")}
</div>
<ul className="grid grid-cols-1 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
{subscribe?.name && (
<li className="flex items-center justify-between">
<span className="line-clamp-2 flex-1 text-muted-foreground">
{subscribe?.name}
</span>
<span>
x <span>{subscribe?.quantity || 1}</span>
</span>
</li>
)}
<li>
<span className="text-muted-foreground">
{t("detail.availableTraffic", "Available Traffic")}
</span>
<span>
<Display type="traffic" unlimited value={subscribe?.traffic} />
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("detail.connectionSpeed", "Connection Speed")}
</span>
<span>
<Display
type="trafficSpeed"
unlimited
value={subscribe?.speed_limit}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("detail.connectedDevices", "Connected Devices")}
</span>
<span>
<Display type="number" unlimited value={subscribe?.device_limit} />
</span>
</li>
</ul>
</>
);
}
@@ -0,0 +1,91 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Label } from "@workspace/ui/components/label";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import type React from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
interface DurationSelectorProps {
quantity: number;
unitTime?: string;
discounts?: Array<{ quantity: number; discount: number }>;
onChange: (value: number) => void;
}
const DurationSelector: React.FC<DurationSelectorProps> = ({
quantity,
unitTime = "Month",
discounts = [],
onChange,
}) => {
const { t } = useTranslation("subscribe");
const handleChange = useCallback(
(value: string) => {
onChange(Number(value));
},
[onChange]
);
const DurationOption: React.FC<{ value: string; label: string }> = ({
value,
label,
}) => (
<div className="relative">
<RadioGroupItem className="peer sr-only" id={value} value={value} />
<Label
className="relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 border-muted bg-popover p-2 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary"
htmlFor={value}
>
{label}
</Label>
</div>
);
const currentDiscount = discounts?.find(
(item) => item.quantity === quantity
)?.discount;
const discountPercentage = currentDiscount ? 100 - currentDiscount : 0;
return (
<>
<div className="font-semibold">
{t("purchaseDuration", "Purchase Duration")}
</div>
<RadioGroup
className="flex flex-wrap gap-3"
onValueChange={handleChange}
value={String(quantity)}
>
{unitTime !== "Minute" && (
<DurationOption label={`1 / ${t(unitTime)}`} value="1" />
)}
{discounts?.map((item) => (
<DurationOption
key={item.quantity}
label={`${item.quantity} / ${t(unitTime)}`}
value={String(item.quantity)}
/>
))}
</RadioGroup>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
{t("discountInfo", "Discount Info")}:
</span>
{discountPercentage > 0 ? (
<Badge className="h-6 text-sm" variant="destructive">
-{discountPercentage}% {t("discount", "Discount")}
</Badge>
) : (
<span className="h-6 text-muted-foreground text-sm">--</span>
)}
</div>
</>
);
};
export default DurationSelector;
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@workspace/ui/components/card";
import { Separator } from "@workspace/ui/components/separator";
import Empty from "@workspace/ui/composed/empty";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { querySubscribeList } from "@workspace/ui/services/user/subscribe";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { SubscribeDetail } from "./detail";
import Purchase from "./purchase";
export default function Subscribe() {
const { t, i18n } = useTranslation("subscribe");
const unitTimeMap: Record<string, string> = {
Day: t("Day", "Day"),
Hour: t("Hour", "Hour"),
Minute: t("Minute", "Minute"),
Month: t("Month", "Month"),
NoLimit: t("NoLimit", "No Limit"),
Year: t("Year", "Year"),
};
const locale = i18n.language;
const [subscribe, setSubscribe] = useState<API.Subscribe>();
const { data } = useQuery({
queryKey: ["querySubscribeList", locale],
queryFn: async () => {
console.log("Fetching subscription list...");
const { data } = await querySubscribeList({ language: locale });
return data.data?.list || [];
},
});
const filteredData = data?.filter((item) => item.show);
return (
<>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3">
{filteredData?.map((item) => (
<Card className="relative flex flex-col" key={item.id}>
<CardHeader className="font-medium text-xl">
{item.name}
</CardHeader>
<CardContent className="*:!text-sm flex flex-grow flex-col gap-3">
{/* <div className='font-semibold'>{t('productDescription')}</div> */}
<ul className="flex flex-grow flex-col gap-3">
{(() => {
let parsedDescription: {
description: string;
features: Array<{
icon: string;
label: string;
type: "default" | "success" | "destructive";
}>;
};
try {
parsedDescription = JSON.parse(item.description);
} catch {
parsedDescription = { description: "", features: [] };
}
const { description, features } = parsedDescription;
return (
<>
{description && (
<li className="text-muted-foreground">
{description}
</li>
)}
{features?.map(
(
feature: {
icon: string;
label: string;
type: "default" | "success" | "destructive";
},
index: number
) => (
<li
className={cn("flex items-center gap-1", {
"text-muted-foreground line-through":
feature.type === "destructive",
})}
key={index}
>
{feature.icon && (
<Icon
className={cn("size-5 text-primary", {
"text-green-500":
feature.type === "success",
"text-destructive":
feature.type === "destructive",
})}
icon={feature.icon}
/>
)}
{feature.label}
</li>
)
)}
</>
);
})()}
</ul>
<SubscribeDetail
subscribe={{
...item,
name: undefined,
}}
/>
</CardContent>
<Separator />
<CardFooter className="flex flex-col gap-2">
<h2 className="pb-8 font-semibold text-2xl sm:text-3xl">
<Display type="currency" value={item.unit_price} />
<span className="font-medium text-base">
/
{unitTimeMap[item.unit_time!] ||
t(item.unit_time || "Month", item.unit_time || "Month")}
</span>
</h2>
<Button
className="absolute bottom-0 w-full rounded-t-none rounded-b-xl"
onClick={() => {
setSubscribe(item);
}}
>
{t("buy", "Buy")}
</Button>
</CardFooter>
</Card>
))}
</div>
{filteredData?.length === 0 && <Empty />}
</div>
<Purchase setSubscribe={setSubscribe} subscribe={subscribe} />
</>
);
}
@@ -0,0 +1,85 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Label } from "@workspace/ui/components/label";
import {
RadioGroup,
RadioGroupItem,
} from "@workspace/ui/components/radio-group";
import { cn } from "@workspace/ui/lib/utils";
import { getAvailablePaymentMethods } from "@workspace/ui/services/user/portal";
import type React from "react";
import { memo } from "react";
import { useTranslation } from "react-i18next";
interface PaymentMethodsProps {
value: number;
onChange: (value: number) => void;
balance?: boolean;
}
const PaymentMethods: React.FC<PaymentMethodsProps> = ({
value,
onChange,
balance = true,
}) => {
const { t } = useTranslation("subscribe");
const { data } = useQuery({
queryKey: ["getAvailablePaymentMethods", { balance }],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
const list = data.data?.list || [];
const methods = balance ? list : list.filter((item) => item.id !== -1);
const defaultMethod = methods.find((item) => item.id)?.id;
if (defaultMethod) onChange(defaultMethod);
return methods;
},
});
return (
<>
<div className="font-semibold">
{t("paymentMethod", "Payment Method")}
</div>
<RadioGroup
className="grid grid-cols-2 gap-2 md:grid-cols-5"
onValueChange={(val) => {
console.log(val);
onChange(Number(val));
}}
value={String(value)}
>
{data?.map((item) => (
<div className="relative" key={item.id}>
<RadioGroupItem
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
id={String(item.id)}
value={String(item.id)}
/>
<Label
className={cn(
"flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover py-2 hover:bg-accent hover:text-accent-foreground",
String(value) === String(item.id) ? "border-primary" : ""
)}
htmlFor={String(item.id)}
>
<div className="flex size-12 items-center justify-center">
<img
alt={item.name}
height={48}
src={item.icon || "/payment/balance.svg"}
width={48}
/>
</div>
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-center">
{item.name}
</span>
</Label>
</div>
))}
</RadioGroup>
</>
);
};
export default memo(PaymentMethods);
@@ -0,0 +1,168 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { Separator } from "@workspace/ui/components/separator";
import { preCreateOrder, purchase } from "@workspace/ui/services/user/order";
import { LoaderCircle } from "lucide-react";
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import CouponInput from "@/sections/subscribe/coupon-input";
import DurationSelector from "@/sections/subscribe/duration-selector";
import PaymentMethods from "@/sections/subscribe/payment-methods";
import { useGlobalStore } from "@/stores/global";
import { SubscribeBilling } from "./billing";
import { SubscribeDetail } from "./detail";
interface PurchaseProps {
subscribe?: API.Subscribe;
setSubscribe: (subscribe?: API.Subscribe) => void;
}
export default function Purchase({
subscribe,
setSubscribe,
}: Readonly<PurchaseProps>) {
const { t } = useTranslation("subscribe");
const { getUserInfo } = useGlobalStore();
const router = useRouter();
const [params, setParams] = useState<Partial<API.PurchaseOrderRequest>>({
quantity: 1,
subscribe_id: 0,
payment: -1,
coupon: "",
});
const [loading, startTransition] = useTransition();
const lastSuccessOrderRef = useRef<any>(null);
const { data: order } = useQuery({
enabled: !!subscribe?.id,
queryKey: ["preCreateOrder", params],
queryFn: async () => {
try {
const { data } = await preCreateOrder({
...params,
subscribe_id: subscribe?.id as number,
} as API.PurchaseOrderRequest);
const result = data.data;
if (result) {
lastSuccessOrderRef.current = result;
}
return result;
} catch (error) {
if (lastSuccessOrderRef.current) {
return lastSuccessOrderRef.current;
}
throw error;
}
},
});
useEffect(() => {
if (subscribe) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscribe?.id,
}));
}
}, [subscribe]);
const handleChange = useCallback(
(field: keyof typeof params, value: string | number) => {
setParams((prev) => ({
...prev,
[field]: value,
}));
},
[]
);
const handleSubmit = useCallback(async () => {
startTransition(async () => {
try {
const response = await purchase(params as API.PurchaseOrderRequest);
const orderNo = response.data.data?.order_no;
if (orderNo) {
getUserInfo();
router.navigate({ to: "/payment", search: { order_no: orderNo } });
}
} catch (_error) {
/* empty */
}
});
}, [params, router, getUserInfo]);
return (
<Dialog
onOpenChange={(open) => {
if (!open) setSubscribe(undefined);
}}
open={!!subscribe?.id}
>
<DialogContent className="flex h-full flex-col overflow-hidden border-none p-0 md:h-auto md:max-w-screen-lg">
<DialogHeader className="p-6 pb-0">
<DialogTitle>{t("buySubscription", "Buy Subscription")}</DialogTitle>
</DialogHeader>
<div className="grid w-full flex-grow gap-3 overflow-auto p-6 pt-0 lg:grid-cols-2">
<Card className="border-transparent shadow-none md:border-inherit md:shadow">
<CardContent className="grid gap-3 text-sm">
<SubscribeDetail
subscribe={{
...subscribe,
quantity: params.quantity,
}}
/>
<Separator />
<SubscribeBilling
order={{
...order,
quantity: params.quantity,
unit_price: subscribe?.unit_price,
}}
/>
</CardContent>
</Card>
<div className="flex flex-col justify-between text-sm">
<div className="mb-6 grid gap-3">
<DurationSelector
discounts={subscribe?.discount}
onChange={(value) => {
handleChange("quantity", value);
}}
quantity={params.quantity as number}
unitTime={subscribe?.unit_time}
/>
<CouponInput
coupon={params.coupon}
onChange={(value) => handleChange("coupon", value)}
/>
<PaymentMethods
onChange={(value) => {
handleChange("payment", value);
}}
value={params.payment as number}
/>
</div>
<Button
className="fixed bottom-0 left-0 w-full md:relative md:mt-6"
disabled={loading}
onClick={handleSubmit}
>
{loading && <LoaderCircle className="mr-2 animate-spin" />}
{t("buyNow", "Buy Now")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,105 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { recharge } from "@workspace/ui/services/user/order";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { LoaderCircle } from "lucide-react";
import type React from "react";
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
import PaymentMethods from "./payment-methods";
export default function Recharge(
props: Readonly<React.ComponentProps<typeof Button>>
) {
const { t } = useTranslation("subscribe");
const { common } = useGlobalStore();
const { currency } = common;
const [open, setOpen] = useState<boolean>(false);
const [loading, startTransition] = useTransition();
const [params, setParams] = useState<API.RechargeOrderRequest>({
amount: 0,
payment: 1,
});
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button {...props}>{t("recharge", "Recharge")}</Button>
</DialogTrigger>
<DialogContent className="flex h-full flex-col overflow-hidden md:h-auto">
<DialogHeader>
<DialogTitle>{t("balanceRecharge", "Balance Recharge")}</DialogTitle>
<DialogDescription>
{t("rechargeDescription", "Recharge your account balance")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col justify-between text-sm">
<div className="grid gap-3">
<div className="font-semibold">
{t("rechargeAmount", "Recharge Amount")}
</div>
<div className="flex">
<EnhancedInput
formatInput={(value) => unitConversion("centsToDollars", value)}
formatOutput={(value) =>
unitConversion("dollarsToCents", value)
}
min={0}
onValueChange={(value) => {
setParams((prev) => ({
...prev,
amount: value as number,
}));
}}
placeholder={t("enterAmount", "Enter Amount")}
prefix={currency.currency_symbol}
suffix={currency.currency_unit}
type="number"
value={params.amount}
/>
</div>
<PaymentMethods
balance={false}
onChange={(value) => setParams({ ...params, payment: value })}
value={params.payment}
/>
</div>
<Button
className="fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6"
disabled={loading || !params.amount}
onClick={() => {
startTransition(async () => {
try {
const response = await recharge(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
window.location.href = `/payment?order_no=${orderNo}`;
setOpen(false);
}
} catch (_error) {
/* empty */
}
});
}}
>
{loading && <LoaderCircle className="mr-2 animate-spin" />}
{t("rechargeNow", "Recharge Now")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,165 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Separator } from "@workspace/ui/components/separator";
import { preCreateOrder, renewal } from "@workspace/ui/services/user/order";
import { LoaderCircle } from "lucide-react";
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import CouponInput from "@/sections/subscribe/coupon-input";
import DurationSelector from "@/sections/subscribe/duration-selector";
import PaymentMethods from "@/sections/subscribe/payment-methods";
import { useGlobalStore } from "@/stores/global";
import { SubscribeBilling } from "./billing";
import { SubscribeDetail } from "./detail";
interface RenewalProps {
id: number;
subscribe: API.Subscribe;
}
export default function Renewal({ id, subscribe }: Readonly<RenewalProps>) {
const { t } = useTranslation("subscribe");
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const [params, setParams] = useState<Partial<API.RenewalOrderRequest>>({
quantity: 1,
payment: -1,
coupon: "",
user_subscribe_id: id,
});
const [loading, startTransition] = useTransition();
const lastSuccessOrderRef = useRef<any>(null);
const { data: order } = useQuery({
enabled: !!subscribe.id && open,
queryKey: ["preCreateOrder", params],
queryFn: async () => {
try {
const { data } = await preCreateOrder({
...params,
subscribe_id: subscribe.id,
} as API.PurchaseOrderRequest);
const result = data.data;
if (result) {
lastSuccessOrderRef.current = result;
}
return result;
} catch (_error) {
if (lastSuccessOrderRef.current) {
return lastSuccessOrderRef.current;
}
}
},
});
useEffect(() => {
if (subscribe.id && id) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscribe.id,
user_subscribe_id: id,
}));
}
}, [subscribe.id, id]);
const handleChange = useCallback(
(field: keyof typeof params, value: string | number) => {
setParams((prev) => ({
...prev,
[field]: value,
}));
},
[]
);
const handleSubmit = useCallback(async () => {
startTransition(async () => {
try {
const response = await renewal(params as API.RenewalOrderRequest);
const orderNo = response.data.data?.order_no;
if (orderNo) {
getUserInfo();
window.location.href = `/payment?order_no=${orderNo}`;
}
} catch (_error) {
/* empty */
}
});
}, [params, getUserInfo]);
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button size="sm">{t("renew", "Renew")}</Button>
</DialogTrigger>
<DialogContent className="flex h-full flex-col overflow-hidden md:h-auto md:max-w-screen-lg">
<DialogHeader>
<DialogTitle>
{t("renewSubscription", "Renew Subscription")}
</DialogTitle>
</DialogHeader>
<div className="grid w-full gap-3 lg:grid-cols-2">
<Card className="border-transparent shadow-none md:border-inherit md:shadow">
<CardContent className="grid gap-3 p-0 text-sm md:p-6">
<SubscribeDetail
subscribe={{
...subscribe,
quantity: params.quantity,
}}
/>
<Separator />
<SubscribeBilling
order={{
...order,
quantity: params.quantity,
unit_price: subscribe?.unit_price,
}}
/>
</CardContent>
</Card>
<div className="flex flex-col justify-between text-sm">
<div className="mb-6 grid gap-3">
<DurationSelector
discounts={subscribe?.discount}
onChange={(value) => {
handleChange("quantity", value);
}}
quantity={params.quantity!}
unitTime={subscribe?.unit_time}
/>
<CouponInput
coupon={params.coupon}
onChange={(value) => handleChange("coupon", value)}
/>
<PaymentMethods
onChange={(value) => {
handleChange("payment", value);
}}
value={params.payment as number}
/>
</div>
<Button
className="fixed bottom-0 left-0 w-full md:relative md:mt-6"
disabled={loading}
onClick={handleSubmit}
>
{loading && <LoaderCircle className="mr-2 animate-spin" />}
{t("buyNow", "Buy Now")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,106 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { resetTraffic } from "@workspace/ui/services/user/order";
import { LoaderCircle } from "lucide-react";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
import PaymentMethods from "./payment-methods";
interface ResetTrafficProps {
id: number;
replacement?: number;
}
export default function ResetTraffic({
id,
replacement,
}: Readonly<ResetTrafficProps>) {
const { t } = useTranslation("subscribe");
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const [params, setParams] = useState<API.ResetTrafficOrderRequest>({
payment: -1,
user_subscribe_id: id,
});
const [loading, startTransition] = useTransition();
useEffect(() => {
if (id) {
setParams((prev) => ({
...prev,
quantity: 1,
user_subscribe_id: id,
}));
}
}, [id]);
if (!replacement) return;
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
{t("resetTraffic", "Reset Traffic")}
</Button>
</DialogTrigger>
<DialogContent className="flex h-full flex-col overflow-hidden md:h-auto">
<DialogHeader>
<DialogTitle>{t("resetTrafficTitle", "Reset Traffic")}</DialogTitle>
<DialogDescription>
{t("resetTrafficDescription", "Reset your subscription traffic")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col justify-between text-sm">
<div className="grid gap-3">
<div className="flex justify-between font-semibold">
<span>{t("resetPrice", "Reset Price")}</span>
<span>
<Display type="currency" value={replacement} />
</span>
</div>
<PaymentMethods
onChange={(value) => {
setParams({
...params,
payment: value,
});
}}
value={params.payment}
/>
</div>
<Button
className="fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6"
disabled={loading}
onClick={async () => {
startTransition(async () => {
try {
const response = await resetTraffic(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
getUserInfo();
window.location.href = `/payment?order_no=${orderNo}`;
}
} catch (error) {
console.log(error);
}
});
}}
>
{loading && <LoaderCircle className="mr-2 animate-spin" />}
{t("buyNow", "Buy Now")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { preUnsubscribe, unsubscribe } from "@workspace/ui/services/user/user";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
interface UnsubscribeProps {
id: number;
allowDeduction?: boolean;
}
export default function Unsubscribe({
id,
allowDeduction,
}: Readonly<UnsubscribeProps>) {
const { t } = useTranslation("subscribe");
const { common, getUserInfo } = useGlobalStore();
const single_model = common.subscribe.single_model;
const [open, setOpen] = useState(false);
const { data } = useQuery({
enabled: Boolean(open && id && allowDeduction),
queryKey: ["preUnsubscribe", id],
queryFn: async () => {
const { data } = await preUnsubscribe({ id });
return data.data?.deduction_amount;
},
});
const handleSubmit = async () => {
try {
await unsubscribe(
{ id },
{
skipErrorHandler: true,
}
);
toast.success(t("unsubscribe.success", "Unsubscribed successfully"));
await getUserInfo();
setOpen(false);
} catch (_error) {
toast.error(t("unsubscribe.failed", "Unsubscribe failed"));
}
};
if (!(single_model || allowDeduction)) return null;
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button size="sm" variant="destructive">
{t("unsubscribe.unsubscribe", "Unsubscribe")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("unsubscribe.confirmUnsubscribe", "Confirm Unsubscribe")}
</DialogTitle>
<DialogDescription>
{t(
"unsubscribe.confirmUnsubscribeDescription",
"Are you sure you want to unsubscribe?"
)}
</DialogDescription>
</DialogHeader>
<p>{t("unsubscribe.residualValue", "Residual Value")}</p>
<p className="font-semibold text-2xl text-primary">
<Display type="currency" value={data} />
</p>
<p className="text-muted-foreground text-sm">
{t(
"unsubscribe.unsubscribeDescription",
"The residual value will be refunded to your account"
)}
</p>
<DialogFooter>
<Button onClick={() => setOpen(false)} variant="outline">
{t("unsubscribe.cancel", "Cancel")}
</Button>
<Button onClick={handleSubmit}>
{t("unsubscribe.confirm", "Confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { ProList } from "@workspace/ui/composed/pro-list/pro-list";
import {
queryUserAffiliate,
queryUserAffiliateList,
} from "@workspace/ui/services/user/user";
import { formatDate } from "@workspace/ui/utils/formatting";
import { Copy } from "lucide-react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
export default function Affiliate() {
const { t } = useTranslation("affiliate");
const { user, common } = useGlobalStore();
const { data } = useQuery({
queryKey: ["queryUserAffiliate"],
queryFn: async () => {
const response = await queryUserAffiliate();
return response.data.data;
},
});
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{t("totalCommission", "Total Commission")}</CardTitle>
<CardDescription>
{t("commissionInfo", "Commission Info")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className="font-bold text-3xl">
<Display type="currency" value={data?.total_commission} />
</span>
<span className="text-muted-foreground text-sm">
({t("commissionRate", "Commission Rate")}:{" "}
{user?.referral_percentage || common?.invite?.referral_percentage}
%)
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="font-medium text-lg">
{t("inviteCode", "Invite Code")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<code className="rounded bg-muted px-2 py-1 font-bold text-2xl">
{user?.refer_code}
</code>
<CopyToClipboard
onCopy={(_, result) => {
if (result) {
toast.success(t("copySuccess", "Copy Success"));
}
}}
text={`${location?.origin}/auth?invite=${user?.refer_code}`}
>
<Button className="gap-2" size="sm" variant="secondary">
<Copy className="h-4 w-4" />
{t("copyInviteLink", "Copy Invite Link")}
</Button>
</CopyToClipboard>
</div>
</CardContent>
</Card>
<ProList<API.UserAffiliate, Record<string, unknown>>
header={{
title: t("inviteRecords", "Invite Records"),
}}
renderItem={(item) => (
<Card className="overflow-hidden">
<CardContent className="p-3 text-sm">
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col">
<li className="font-semibold">
<span className="text-muted-foreground">
{t("userIdentifier", "User Identifier")}
</span>
<span>{item.identifier}</span>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("registrationTime", "Registration Time")}
</span>
<time>{formatDate(item.registered_at)}</time>
</li>
</ul>
</CardContent>
</Card>
)}
request={async (pagination, filter) => {
const response = await queryUserAffiliateList({
...pagination,
...filter,
});
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
/>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Card } from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import Empty from "@workspace/ui/composed/empty";
import { Icon } from "@workspace/ui/composed/icon";
import { Markdown } from "@workspace/ui/composed/markdown";
import { queryAnnouncement } from "@workspace/ui/services/user/announcement";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
export default function Announcement({ type }: { type: "popup" | "pinned" }) {
const { t } = useTranslation("dashboard");
const { user } = useGlobalStore();
const { data } = useQuery({
queryKey: ["announcement", type],
queryFn: async () => {
const result = await queryAnnouncement(
{
page: 1,
size: 10,
pinned: type === "pinned",
popup: type === "popup",
},
{
skipErrorHandler: true,
}
);
return result.data.data?.announcements.find((item) => item[type]) || null;
},
enabled: !!user,
});
if (!data) return null;
if (type === "popup") {
return (
<Dialog defaultOpen={!!data}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{data?.title}</DialogTitle>
</DialogHeader>
<Markdown>{data?.content}</Markdown>
</DialogContent>
</Dialog>
);
}
if (type === "pinned") {
return (
<>
<h2 className="flex items-center gap-1.5 font-semibold">
<Icon className="size-5" icon="uil:bell" />
{t("latestAnnouncement", "Latest Announcement")}
</h2>
<Card className="p-6">
{data?.content ? <Markdown>{data?.content}</Markdown> : <Empty />}
</Card>
</>
);
}
}
@@ -0,0 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Timeline } from "@workspace/ui/components/timeline";
import Empty from "@workspace/ui/composed/empty";
import { Markdown } from "@workspace/ui/composed/markdown";
import { queryAnnouncement } from "@workspace/ui/services/user/announcement";
export default function Announcement() {
const { data } = useQuery({
queryKey: ["queryAnnouncement"],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: 1,
size: 99,
pinned: false,
popup: false,
});
return data.data?.announcements || [];
},
});
return data && data.length > 0 ? (
<Timeline
data={
data.map((item) => ({
title: item.title,
content: <Markdown>{item.content}</Markdown>,
})) || []
}
/>
) : (
<Empty border />
);
}
@@ -0,0 +1,562 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Separator } from "@workspace/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { getClient, getStat } from "@workspace/ui/services/common/common";
import {
queryUserSubscribe,
resetUserSubscribeToken,
} from "@workspace/ui/services/user/user";
import { differenceInDays, formatDate } from "@workspace/ui/utils/formatting";
import { isBrowser } from "@workspace/ui/utils/index";
import { QRCodeCanvas } from "qrcode.react";
import React, { useState } from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
import { getPlatform } from "@/utils/common";
import Subscribe from "../../subscribe";
import Renewal from "../../subscribe/renewal";
import ResetTraffic from "../../subscribe/reset-traffic";
import Unsubscribe from "../../subscribe/unsubscribe";
const platforms: (keyof API.DownloadLink)[] = [
"windows",
"mac",
"linux",
"ios",
"android",
"harmony",
];
export default function Content() {
const { t } = useTranslation("dashboard");
const { getUserSubscribe, getAppSubLink } = useGlobalStore();
const [protocol, setProtocol] = useState("");
const {
data: userSubscribe = [],
refetch,
isLoading,
} = useQuery({
queryKey: ["queryUserSubscribe"],
queryFn: async () => {
const { data } = await queryUserSubscribe();
return data.data?.list || [];
},
});
const { data: applications } = useQuery({
queryKey: ["getClient"],
queryFn: async () => {
const { data } = await getClient();
return data.data?.list || [];
},
});
const availablePlatforms = React.useMemo(() => {
if (!applications || applications.length === 0) return platforms;
const platformsSet = new Set<keyof API.DownloadLink>();
applications.forEach((app) => {
if (app.download_link) {
platforms.forEach((platform) => {
if (app.download_link?.[platform]) {
platformsSet.add(platform);
}
});
}
});
return platforms.filter((platform) => platformsSet.has(platform));
}, [applications]);
const [platform, setPlatform] = useState<keyof API.DownloadLink>(() => {
const detectedPlatform =
getPlatform() === "macos"
? "mac"
: (getPlatform() as keyof API.DownloadLink);
return detectedPlatform;
});
React.useEffect(() => {
if (
availablePlatforms.length > 0 &&
!availablePlatforms.includes(platform)
) {
const firstAvailablePlatform = availablePlatforms[0];
if (firstAvailablePlatform) {
setPlatform(firstAvailablePlatform);
}
}
}, [availablePlatforms, platform]);
const { data } = useQuery({
queryKey: ["getStat"],
queryFn: async () => {
const { data } = await getStat({
skipErrorHandler: true,
});
return data.data;
},
refetchOnWindowFocus: false,
});
const statusWatermarks = {
2: t("finished", "Finished"),
3: t("expired", "Expired"),
4: t("deducted", "Deducted"),
};
return (
<>
{userSubscribe.length ? (
<>
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-1.5 font-semibold">
<Icon className="size-5" icon="uil:servers" />
{t("mySubscriptions", "My Subscriptions")}
</h2>
<div className="flex gap-2">
<Button
className={isLoading ? "animate-pulse" : ""}
onClick={() => {
refetch();
}}
size="sm"
variant="outline"
>
<Icon icon="uil:sync" />
</Button>
<Button asChild size="sm">
<Link to="/subscribe">
{t("purchaseSubscription", "Purchase Subscription")}
</Link>
</Button>
</div>
</div>
<div className="flex flex-wrap justify-between gap-4">
{availablePlatforms.length > 0 && (
<Tabs
className="w-full max-w-full md:w-auto"
onValueChange={(value) =>
setPlatform(value as keyof API.DownloadLink)
}
value={platform}
>
<TabsList className="flex *:flex-auto">
{availablePlatforms.map((item) => (
<TabsTrigger
className="px-1 lg:px-3"
key={item}
value={item}
>
<Icon
className="size-5"
icon={`${
{
windows: "mdi:microsoft-windows",
mac: "uil:apple",
linux: "uil:linux",
ios: "simple-icons:ios",
android: "uil:android",
harmony: "simple-icons:harmonyos",
}[item]
}`}
/>
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{data?.protocol && data?.protocol.length > 1 && (
<Tabs
className="w-full max-w-full md:w-auto"
onValueChange={setProtocol}
value={protocol}
>
<TabsList className="flex *:flex-auto">
{["all", ...(data?.protocol || [])].map((item) => (
<TabsTrigger
className="px-1 uppercase lg:px-3"
key={item}
value={item === "all" ? "" : item}
>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
{userSubscribe.map((item) => (
<Card
className={cn("relative", {
"relative opacity-80 grayscale": item.status === 3,
"relative hidden opacity-60 blur-[0.3px] grayscale":
item.status === 4,
})}
key={item.id}
>
{item.status >= 2 && (
<div
className={cn(
"pointer-events-none absolute top-0 left-0 z-10 h-full w-full overflow-hidden mix-blend-difference brightness-150 contrast-200 invert-[0.2]",
{
"text-destructive": item.status === 2,
"text-white": item.status === 3 || item.status === 4,
}
)}
>
<div className="absolute inset-0">
{Array.from({ length: 16 }).map((_, i) => {
const row = Math.floor(i / 4);
const col = i % 4;
const top = 10 + row * 25 + (col % 2 === 0 ? 5 : -5);
const left = 5 + col * 30 + (row % 2 === 0 ? 0 : 10);
return (
<span
className="absolute rotate-[-30deg] whitespace-nowrap font-black text-lg opacity-40 shadow-[0px_0px_1px_rgba(255,255,255,0.5)]"
key={i}
style={{
top: `${top}%`,
left: `${left}%`,
}}
>
{
statusWatermarks[
item.status as keyof typeof statusWatermarks
]
}
</span>
);
})}
</div>
</div>
)}
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0">
<CardTitle className="font-medium">
{item.subscribe.name}
<p className="mt-1 text-foreground/50 text-sm">
{formatDate(item.start_time)}
</p>
</CardTitle>
{item.status !== 4 && (
<div className="flex flex-wrap gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
{t("resetSubscription", "Reset Subscription")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("prompt", "Prompt")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"confirmResetSubscription",
"Are you sure you want to reset your subscription?"
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await resetUserSubscribeToken({
user_subscribe_id: item.id,
});
await refetch();
toast.success(t("resetSuccess", "Reset Success"));
}}
>
{t("confirm", "Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResetTraffic
id={item.id}
replacement={item.subscribe.replacement}
/>
<Renewal id={item.id} subscribe={item.subscribe} />
<Unsubscribe
allowDeduction={item.subscribe.allow_deduction}
id={item.id}
/>
</div>
)}
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4">
<li>
<span className="text-muted-foreground">
{t("used", "Used")}
</span>
<span className="font-bold text-2xl">
<Display
type="traffic"
unlimited={!item.traffic}
value={item.upload + item.download}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("totalTraffic", "Total Traffic")}
</span>
<span className="font-bold text-2xl">
<Display
type="traffic"
unlimited={!item.traffic}
value={item.traffic}
/>
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("nextResetDays", "Next Reset Days")}
</span>
<span className="font-semibold text-2xl">
{item.reset_time
? differenceInDays(
new Date(item.reset_time),
new Date()
)
: t("noReset", "No Reset")}
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("expirationDays", "Expiration Days")}
</span>
<span className="font-semibold text-2xl">
{}
{item.expire_time
? differenceInDays(
new Date(item.expire_time),
new Date()
) || t("unknown", "Unknown")
: t("noLimit", "No Limit")}
</span>
</li>
</ul>
<Separator className="mt-4" />
<Accordion
className="w-full"
collapsible
defaultValue="0"
type="single"
>
{getUserSubscribe(item.token, protocol)?.map((url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className="hover:no-underline">
<div className="flex w-full flex-row items-center justify-between">
<CardTitle className="font-medium text-sm">
{t("subscriptionUrl", "Subscription URL")}{" "}
{index + 1}
</CardTitle>
<CopyToClipboard
onCopy={(_, result) => {
if (result) {
toast.success(t("copySuccess", "Copy Success"));
}
}}
text={url}
>
<span
className="mr-4 flex cursor-pointer rounded p-2 text-primary text-sm hover:bg-accent"
onClick={(e) => e.stopPropagation()}
>
<Icon className="mr-2 size-5" icon="uil:copy" />
{t("copy", "Copy")}
</span>
</CopyToClipboard>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{applications
?.filter(
(application) =>
!!(
application.download_link?.[platform] &&
application.scheme
)
)
.map((application) => {
const downloadUrl =
application.download_link?.[platform];
const handleCopy = (
_: string,
result: boolean
) => {
if (result) {
const href = getAppSubLink(
url,
application.scheme
);
const showSuccessMessage = () => {
toast.success(
<>
<p>
{t("copySuccess", "Copy Success")}
</p>
<br />
<p>
{t(
"manualImportMessage",
"Please import manually"
)}
</p>
</>
);
};
if (isBrowser() && href) {
window.location.href = href;
const checkRedirect = setTimeout(() => {
if (window.location.href !== href) {
showSuccessMessage();
}
clearTimeout(checkRedirect);
}, 1000);
return;
}
showSuccessMessage();
}
};
return (
<div
className="flex size-full flex-col items-center justify-between gap-2 text-muted-foreground text-xs"
key={application.name}
>
<span>{application.name}</span>
{application.icon && (
<img
alt={application.name}
className="p-1"
height={64}
src={application.icon}
width={64}
/>
)}
<div className="flex">
{downloadUrl && (
<Button
asChild
className={
application.scheme
? "rounded-r-none px-1.5"
: "px-1.5"
}
size="sm"
variant="secondary"
>
<a
href={downloadUrl}
rel="noopener noreferrer"
target="_blank"
>
{t("download", "Download")}
</a>
</Button>
)}
{application.scheme && (
<CopyToClipboard
onCopy={handleCopy}
text={getAppSubLink(
url,
application.scheme
)}
>
<Button
className={
downloadUrl
? "rounded-l-none p-2"
: "p-2"
}
size="sm"
>
{t("import", "Import")}
</Button>
</CopyToClipboard>
)}
</div>
</div>
);
})}
<div className="hidden size-full flex-col items-center justify-between gap-2 text-muted-foreground text-sm lg:flex">
<span>{t("qrCode", "QR Code")}</span>
<QRCodeCanvas
bgColor="transparent"
fgColor="rgb(59, 130, 246)"
size={80}
value={url}
/>
<span className="text-center">
{t("scanToSubscribe", "Scan to Subscribe")}
</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
))}
</>
) : (
<>
<h2 className="flex items-center gap-1.5 font-semibold">
<Icon className="size-5" icon="uil:shop" />
{t("purchaseSubscription", "Purchase Subscription")}
</h2>
<Subscribe />
</>
)}
</>
);
}
@@ -0,0 +1,11 @@
import Announcement from "../announcement";
import Content from "./content";
export default function Dashboard() {
return (
<div className="flex min-h-[calc(100vh-64px-58px-32px-114px)] w-full flex-col gap-4 overflow-hidden">
<Announcement type="pinned" />
<Content />
</div>
);
}
@@ -0,0 +1,34 @@
import { cn } from "@workspace/ui/lib/utils";
import { motion } from "framer-motion";
export const CloseIcon = ({ className }: { className?: string }) => (
<motion.svg
animate={{
opacity: 1,
}}
className={cn("h-4 w-4", className)}
exit={{
opacity: 0,
transition: {
duration: 0.05,
},
}}
fill="none"
height="24"
initial={{
opacity: 0,
}}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Close</title>
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</motion.svg>
);
@@ -0,0 +1,144 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Avatar, AvatarFallback } from "@workspace/ui/components/avatar";
import { buttonVariants } from "@workspace/ui/components/button";
import { Markdown } from "@workspace/ui/composed/markdown";
import { useOutsideClick } from "@workspace/ui/hooks/use-outside-click";
import { cn } from "@workspace/ui/lib/utils";
import { queryDocumentDetail } from "@workspace/ui/services/user/document";
import { formatDate } from "@workspace/ui/utils/formatting";
import { AnimatePresence, motion } from "framer-motion";
import { type RefObject, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CloseIcon } from "./close-icon";
export function DocumentButton({ items }: { items: API.Document[] }) {
const { t } = useTranslation("document");
const [active, setActive] = useState<API.Document | boolean | null>(null);
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const { data } = useQuery({
enabled: !!(active as API.Document)?.id,
queryKey: ["queryDocumentDetail", (active as API.Document)?.id],
queryFn: async () => {
const { data } = await queryDocumentDetail({
id: (active as API.Document)?.id,
});
return data.data?.content;
},
});
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setActive(false);
}
}
if (active && typeof active === "object") {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [active]);
useOutsideClick(ref as RefObject<HTMLDivElement>, () => setActive(null));
return (
<>
<AnimatePresence>
{active && typeof active === "object" && (
<motion.div
animate={{ opacity: 1 }}
className="fixed inset-0 z-10 h-full w-full bg-black/20"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<AnimatePresence>
{active && typeof active === "object" ? (
<div className="fixed inset-0 z-[100] grid place-items-center">
<motion.button
animate={{
opacity: 1,
}}
className="absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full bg-foreground text-white dark:text-black"
exit={{
opacity: 0,
transition: {
duration: 0.05,
},
}}
initial={{
opacity: 0,
}}
key={`button-${active.title}-${id}`}
layout
onClick={() => setActive(null)}
>
<CloseIcon />
</motion.button>
<motion.div
className="flex size-full flex-col overflow-auto bg-muted p-6 sm:rounded"
layoutId={`card-${active.id}-${id}`}
ref={ref}
>
<Markdown>{data || ""}</Markdown>
</motion.div>
</div>
) : null}
</AnimatePresence>
<div className="flex w-full flex-col gap-4">
{items.map((item) => (
<motion.div
className="flex cursor-pointer items-center justify-between rounded-xl border bg-background p-4 hover:bg-accent"
key={`card-${item.id}-${id}`}
layoutId={`card-${item.id}-${id}`}
onClick={() => setActive(item)}
>
<div className="flex flex-row items-center gap-4">
<motion.div layoutId={`image-${item.id}-${id}`}>
<Avatar className="size-12">
<AvatarFallback className="bg-primary/80 text-white">
{item.title.split("")[0]}
</AvatarFallback>
</Avatar>
</motion.div>
<div className="">
<motion.h3
className="font-medium"
layoutId={`title-${item.id}-${id}`}
>
{item.title}
</motion.h3>
<motion.p
className="text-neutral-600 text-sm dark:text-neutral-400"
layoutId={`description-${item.id}-${id}`}
>
{formatDate(item.updated_at)}
</motion.p>
</div>
</div>
<motion.button
className={cn(
buttonVariants({
variant: "secondary",
}),
"rounded-full"
)}
layoutId={`button-${item.id}-${id}`}
>
{t("read", "Read")}
</motion.button>
</motion.div>
))}
</div>
</>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import Empty from "@workspace/ui/composed/empty";
import { queryDocumentList } from "@workspace/ui/services/user/document";
import { useTranslation } from "react-i18next";
import { TUTORIAL_DOCUMENT } from "@/config";
import { DocumentButton } from "@/sections/user/document/document-button";
import { getTutorialList } from "@/sections/user/document/tutorial";
import { TutorialButton } from "@/sections/user/document/tutorial-button";
export default function Document() {
const { t, i18n } = useTranslation("document");
const locale = i18n.language;
const { data } = useQuery({
queryKey: ["queryDocumentList"],
queryFn: async () => {
const response = await queryDocumentList();
const list = response.data.data?.list || [];
return {
tags: Array.from(
new Set(
list.reduce((acc: string[], item) => acc.concat(item.tags), [])
)
),
list,
};
},
});
const { tags, list: DocumentList } = data || { tags: [], list: [] };
const { data: TutorialList } = useQuery({
queryKey: ["getTutorialList", locale],
queryFn: async () => {
const list = await getTutorialList();
return list.get(locale);
},
enabled: TUTORIAL_DOCUMENT === "true",
});
if (
(!DocumentList || DocumentList.length === 0) &&
(!TutorialList || TutorialList.length === 0)
) {
return <Empty border />;
}
return (
<div className="space-y-4">
{DocumentList?.length > 0 && (
<>
<h2 className="flex items-center gap-1.5 font-semibold">
{t("document", "Document")}
</h2>
<Tabs defaultValue="all">
<TabsList className="h-full flex-wrap">
<TabsTrigger value="all">{t("all", "All")}</TabsTrigger>
{tags?.map((item) => (
<TabsTrigger key={item} value={item}>
{item}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="all">
<DocumentButton items={DocumentList} />
</TabsContent>
{tags?.map((item) => (
<TabsContent key={item} value={item}>
<DocumentButton
items={DocumentList.filter((docs) =>
item ? docs.tags.includes(item) : true
)}
/>
</TabsContent>
))}
</Tabs>
</>
)}
{TutorialList && TutorialList?.length > 0 && (
<>
<h2 className="flex items-center gap-1.5 font-semibold">
{t("tutorial", "Tutorial")}
</h2>
<Tabs defaultValue={TutorialList?.[0]?.title}>
<TabsList className="h-full flex-wrap">
{TutorialList?.map((tutorial) => (
<TabsTrigger key={tutorial.title} value={tutorial.title}>
{tutorial.title}
</TabsTrigger>
))}
</TabsList>
{TutorialList?.map((tutorial) => (
<TabsContent key={tutorial.title} value={tutorial.title}>
<TutorialButton
items={
tutorial.subItems && tutorial.subItems?.length > 0
? tutorial.subItems
: [tutorial]
}
key={tutorial.path}
/>
</TabsContent>
))}
</Tabs>
</>
)}
</div>
);
}
@@ -0,0 +1,170 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import { buttonVariants } from "@workspace/ui/components/button";
import { Markdown } from "@workspace/ui/composed/markdown";
import { useOutsideClick } from "@workspace/ui/hooks/use-outside-click";
import { cn } from "@workspace/ui/lib/utils";
import { formatDate } from "@workspace/ui/utils/formatting";
import { AnimatePresence, motion } from "framer-motion";
import { type RefObject, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { getTutorial } from "@/sections/user/document/tutorial";
import { CloseIcon } from "./close-icon";
interface Item {
path: string;
title: string;
updated_at?: string;
icon?: string;
}
export function TutorialButton({ items }: { items: Item[] }) {
const { t } = useTranslation("document");
const [active, setActive] = useState<Item | boolean | null>(null);
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const { data } = useQuery({
enabled: !!(active as Item)?.path,
queryKey: ["getTutorial", (active as Item)?.path],
queryFn: async () => {
const markdown = await getTutorial((active as Item)?.path);
return markdown;
},
});
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setActive(false);
}
}
if (active && typeof active === "object") {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "auto";
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [active]);
useOutsideClick(ref as RefObject<HTMLDivElement>, () => setActive(null));
return (
<>
<AnimatePresence>
{active && typeof active === "object" && (
<motion.div
animate={{ opacity: 1 }}
className="fixed inset-0 z-10 h-full w-full bg-black/20"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<AnimatePresence>
{active && typeof active === "object" ? (
<div className="fixed inset-0 z-[100] grid place-items-center">
<motion.button
animate={{
opacity: 1,
}}
className="absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded-full bg-foreground text-white dark:text-black"
exit={{
opacity: 0,
transition: {
duration: 0.05,
},
}}
initial={{
opacity: 0,
}}
key={`button-${active.title}-${id}`}
layout
onClick={() => setActive(null)}
>
<CloseIcon />
</motion.button>
<motion.div
className="flex size-full flex-col overflow-auto bg-muted p-6 sm:rounded"
layoutId={`card-${active.title}-${id}`}
ref={ref}
>
<Markdown
components={{
img: ({ node, className, ...props }: any) => (
<img
{...props}
alt=""
className="my-4 inline-block size-auto max-h-96"
height={384}
width={800}
/>
),
}}
>
{data?.content || ""}
</Markdown>
</motion.div>
</div>
) : null}
</AnimatePresence>
<div className="flex w-full flex-col gap-4">
{items.map((item) => (
<motion.div
className="flex cursor-pointer items-center justify-between rounded-xl border bg-background p-4 hover:bg-accent"
key={`card-${item.title}-${id}`}
layoutId={`card-${item.title}-${id}`}
onClick={() => setActive(item)}
>
<div className="flex flex-row items-center gap-4">
<motion.div layoutId={`image-${item.title}-${id}`}>
<Avatar className="size-12">
<AvatarImage alt={item.title ?? ""} src={item.icon ?? ""} />
<AvatarFallback className="bg-primary/80 text-white">
{item.title.split("")[0]}
</AvatarFallback>
</Avatar>
</motion.div>
<div className="">
<motion.h3
className="font-medium"
layoutId={`title-${item.title}-${id}`}
>
{item.title}
</motion.h3>
{item.updated_at && (
<motion.p
className="text-center text-neutral-600 md:text-left dark:text-neutral-400"
layoutId={`description-${item.title}-${id}`}
>
{formatDate(new Date(item.updated_at), false)}
</motion.p>
)}
</div>
</div>
<motion.button
className={cn(
buttonVariants({
variant: "secondary",
}),
"rounded-full"
)}
layoutId={`button-${item.title}-${id}`}
>
{t("read", "Read")}
</motion.button>
</motion.div>
))}
</div>
</>
);
}
@@ -0,0 +1,136 @@
import yaml from "js-yaml";
import { CDN_URL } from "@/config";
const BASE_URL = `${CDN_URL}/gh/perfect-panel/ppanel-tutorial`;
// async function getVersion() {
// // API rate limit: 60 requests per hour
// const response = await fetch(
// 'https://data.jsdelivr.com/v1/stats/packages/gh/perfect-panel/ppanel-tutorial/versions',
// );
// const json = await response.json();
// return json[0].version;
// }
async function getVersionPath() {
// return getVersion()
// .then((version) => `${BASE_URL}@${version}`)
// .catch((error) => {
// console.warn('Error fetching the version:', error);
// return `${BASE_URL}@latest`;
// });
return `${BASE_URL}@latest`;
}
export async function getTutorial(path: string): Promise<{
config?: Record<string, unknown>;
content: string;
}> {
const versionPath = await getVersionPath();
try {
const url = `${versionPath}/${path}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const text = await response.text();
const match = text.match(/^---\n([\s\S]+?)\n---\n([\s\S]*)$/);
let data: Record<string, unknown> = {};
let content = text;
if (match) {
try {
data = (yaml.load(match[1] || "") as Record<string, unknown>) || {};
content = match[2] || "";
} catch (e) {
console.error("Error parsing YAML frontmatter:", e);
}
}
const markdown = addPrefixToImageUrls(content, getUrlPrefix(url));
return {
config: data,
content: markdown,
};
} catch (error) {
console.error("Error fetching the markdown file:", error);
throw error;
}
}
type TutorialItem = {
title: string;
path: string;
subItems?: TutorialItem[];
};
const processIcon = (item: TutorialItem) => {
if (
"icon" in item &&
typeof item.icon === "string" &&
!item.icon.startsWith("http")
) {
item.icon = `${BASE_URL}/${item.icon}`;
}
};
export async function getTutorialList() {
const { config, content } = await getTutorial("SUMMARY.md");
const navigation = config as Record<string, TutorialItem[]> | undefined;
if (!navigation) {
return parseTutorialToMap(content);
}
Object.values(navigation)
.flat()
.forEach((item) => {
item.subItems?.forEach(processIcon);
});
return new Map(Object.entries(navigation));
}
function parseTutorialToMap(markdown: string): Map<string, TutorialItem[]> {
const map = new Map<string, TutorialItem[]>();
let currentSection = "";
const lines = markdown.split("\n");
for (const line of lines) {
if (line.startsWith("## ")) {
currentSection = line.replace("## ", "").trim();
map.set(currentSection, []);
} else if (line.startsWith("* ")) {
const [, text, link] = line.match(/\* \[(.*?)\]\((.*?)\)/) || [];
if (text && link) {
if (!map.has(currentSection)) {
map.set(currentSection, []);
}
map.get(currentSection)!.push({ title: text, path: link });
}
} else if (line.startsWith(" * ")) {
const [, text, link] = line.match(/\* \[(.*?)\]\((.*?)\)/) || [];
if (text && link) {
const lastItem = map.get(currentSection)?.slice(-1)[0];
if (lastItem) {
if (!lastItem.subItems) {
lastItem.subItems = [];
}
lastItem.subItems.push({ title: text, path: link });
}
}
}
}
return map;
}
function getUrlPrefix(url: string): string {
return url.replace(/\/[^/]+\.md$/, "/");
}
function addPrefixToImageUrls(markdown: string, prefix: string): string {
return markdown.replace(
/!\[(.*?)\]\((.*?)\)/g,
(_match, imgAlt, imgUrl) =>
`![${imgAlt}](${imgUrl.startsWith("http") ? imgUrl : `${prefix}${imgUrl}`})`
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { Outlet } from "@tanstack/react-router";
import {
SidebarInset,
SidebarProvider,
} from "@workspace/ui/components/sidebar";
import Announcement from "@/sections/user/announcement";
import { SidebarLeft } from "./sidebar-left";
import { SidebarRight } from "./sidebar-right";
export default function UserLayout() {
return (
<SidebarProvider className="container">
<SidebarLeft className="sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex" />
<SidebarInset className="relative p-4">
<Outlet />
</SidebarInset>
<SidebarRight className="sticky top-[84px] hidden w-52 border-r-0 bg-transparent 2xl:flex" />
<Announcement type="popup" />
</SidebarProvider>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { Link } from "@tanstack/react-router";
import { Button, buttonVariants } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
ProList,
type ProListActions,
} from "@workspace/ui/composed/pro-list/pro-list";
import { closeOrder, queryOrderList } from "@workspace/ui/services/user/order";
import { formatDate } from "@workspace/ui/utils/formatting";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
export default function Order() {
const { t } = useTranslation("order");
const statusMap: Record<number, string> = {
0: t("status.0", "Status"),
1: t("status.1", "Pending"),
2: t("status.2", "Paid"),
3: t("status.3", "Cancelled"),
4: t("status.4", "Closed"),
5: t("status.5", "Completed"),
};
const typeMap: Record<number, string> = {
0: t("type.0", "Type"),
1: t("type.1", "New Purchase"),
2: t("type.2", "Renewal"),
3: t("type.3", "Reset Traffic"),
4: t("type.4", "Recharge"),
};
const ref = useRef<ProListActions>(null);
return (
<ProList<API.OrderDetail, Record<string, unknown>>
action={ref}
renderItem={(item) => (
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>
{t("orderNo", "Order No")}
<p className="text-sm">{item.order_no}</p>
</CardTitle>
<CardDescription className="flex gap-2">
{item.status === 1 ? (
<>
<Link
className={buttonVariants({ size: "sm" })}
key="payment"
search={{ order_no: item.order_no }}
to="/payment"
>
{t("payment", "Payment")}
</Link>
<Button
key="cancel"
onClick={async () => {
await closeOrder({ orderNo: item.order_no });
ref.current?.refresh();
}}
size="sm"
variant="destructive"
>
{t("cancel", "Cancel")}
</Button>
</>
) : (
<Link
className={buttonVariants({ size: "sm" })}
key="detail"
search={{ order_no: item.order_no }}
to="/payment"
>
{t("detail", "Detail")}
</Link>
)}
</CardDescription>
</CardHeader>
<CardContent className="text-sm">
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-4">
<li>
<span className="text-muted-foreground">
{t("name", "Product Name")}
</span>
<span>
{item.subscribe.name ||
typeMap[item.type] ||
t(`type.${item.type}`, "Unknown Type")}
</span>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("paymentAmount", "Amount")}
</span>
<span>
<Display type="currency" value={item.amount} />
</span>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("status.0", "Status")}
</span>
<span>
{statusMap[item.status] ||
t(`status.${item.status}`, "Unknown Status")}
</span>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("createdAt", "Created At")}
</span>
<time>{formatDate(item.created_at)}</time>
</li>
</ul>
</CardContent>
</Card>
)}
request={async (pagination, filter) => {
const response = await queryOrderList({ ...pagination, ...filter });
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
/>
);
}
@@ -0,0 +1,300 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, Link } from "@tanstack/react-router";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Separator } from "@workspace/ui/components/separator";
import { Icon } from "@workspace/ui/composed/icon";
import { queryOrderDetail } from "@workspace/ui/services/user/order";
import { purchaseCheckout } from "@workspace/ui/services/user/portal";
import { formatDate } from "@workspace/ui/utils/formatting";
import { useCountDown } from "ahooks";
import { addMinutes, format } from "date-fns";
import { QRCodeCanvas } from "qrcode.react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import { SubscribeBilling } from "@/sections/subscribe/billing";
import { SubscribeDetail } from "@/sections/subscribe/detail";
import { useGlobalStore } from "@/stores/global";
import StripePayment from "./stripe";
const routeApi = getRouteApi("/(main)/payment");
export default function Page() {
const { t } = useTranslation("order");
const { getUserInfo } = useGlobalStore();
const { order_no } = routeApi.useSearch() as { order_no?: string };
const [enabled, setEnabled] = useState<boolean>(!!order_no);
useEffect(() => {
if (order_no) {
setEnabled(true);
}
}, [order_no]);
const { data } = useQuery({
enabled: enabled && !!order_no,
queryKey: ["queryOrderDetail", order_no],
queryFn: async () => {
const { data } = await queryOrderDetail({ order_no: order_no! });
if (data?.data?.status !== 1) {
getUserInfo();
setEnabled(false);
}
return data?.data;
},
refetchInterval: 3000,
});
const { data: payment } = useQuery({
enabled: !!order_no && data?.status === 1,
queryKey: ["purchaseCheckout", order_no],
queryFn: async () => {
const { data } = await purchaseCheckout({
orderNo: order_no!,
returnUrl: window.location.href,
});
if (data.data?.type === "url" && data.data.checkout_url) {
window.open(data.data.checkout_url, "_blank");
}
return data?.data;
},
});
const [countDown, formattedRes] = useCountDown({
targetDate:
data &&
format(addMinutes(data?.created_at, 15), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
});
const { hours, minutes, seconds } = formattedRes;
const countdownDisplay =
countDown > 0 ? (
<>
{hours.toString().length === 1 ? `0${hours}` : hours} :{" "}
{minutes.toString().length === 1 ? `0${minutes}` : minutes} :{" "}
{seconds.toString().length === 1 ? `0${seconds}` : seconds}
</>
) : (
t("timeExpired", "Time Expired")
);
return (
<div className="container grid gap-4 pt-16 xl:grid-cols-2">
<Card className="order-2 gap-0 xl:order-1">
<CardHeader className="flex flex-row items-start">
<div className="grid gap-0.5">
<CardTitle className="flex flex-col text-lg">
{t("orderNumber", "Order Number")}
<span>{data?.orderNo}</span>
</CardTitle>
<CardDescription>
{t("createdAt", "Created At")}: {formatDate(data?.created_at)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="grid gap-3 p-6 text-sm">
<div className="font-semibold">
{t("paymentMethod", "Payment Method")}
</div>
<dl className="grid gap-3">
<div className="flex items-center justify-between">
<dt className="text-muted-foreground">
<Badge>{data?.payment.name || data?.payment.platform}</Badge>
</dt>
</div>
</dl>
<Separator />
{data?.type && [1, 2].includes(data.type) && (
<SubscribeDetail
subscribe={{
...data?.subscribe,
quantity: data?.quantity,
}}
/>
)}
{data?.type === 3 && (
<>
<div className="font-semibold">
{t("resetTraffic", "Reset Traffic")}
</div>
<ul className="grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
<li className="flex items-center justify-between">
<span className="line-clamp-2 flex-1 text-muted-foreground">
{t("resetPrice", "Reset Price")}
</span>
<span>
<Display type="currency" value={data.amount} />
</span>
</li>
</ul>
</>
)}
{data?.type === 4 && (
<>
<div className="font-semibold">
{t("balanceRecharge", "Balance Recharge")}
</div>
<ul className="grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1">
<li className="flex items-center justify-between">
<span className="line-clamp-2 flex-1 text-muted-foreground">
{t("rechargeAmount", "Recharge Amount")}
</span>
<span>
<Display type="currency" value={data.amount} />
</span>
</li>
</ul>
</>
)}
<Separator />
<SubscribeBilling
order={{
...data,
unit_price: data?.subscribe?.unit_price,
}}
/>
</CardContent>
</Card>
<Card className="order-1 flex flex-auto items-center justify-center xl:order-2">
<CardContent className="py-16">
{data?.status && [2, 5].includes(data?.status) && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("paymentSuccess", "Payment Success")}
</h3>
<Icon
className="text-7xl text-green-500"
icon="mdi:success-circle-outline"
/>
<div className="flex gap-4">
<Button asChild>
<Link to="/dashboard">
{t("subscribeNow", "Subscribe Now")}
</Link>
</Button>
<Button variant="outline">
<Link to="/document">
{t("viewDocument", "View Document")}
</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === "url" && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("waitingForPayment", "Waiting For Payment")}
</h3>
<p className="flex items-center font-bold text-3xl">
{countdownDisplay}
</p>
<Icon
className="text-7xl text-muted-foreground"
icon="mdi:access-time"
/>
<div className="flex gap-4">
<Button
onClick={() => {
if (payment?.checkout_url) {
window.location.href = payment?.checkout_url;
}
}}
>
{t("goToPayment", "Go To Payment")}
</Button>
<Button variant="outline">
<Link to="/subscribe">
{t("productList", "Product List")}
</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === "qr" && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("scanToPay", "Scan To Pay")}
</h3>
<p className="flex items-center font-bold text-3xl">
{countdownDisplay}
</p>
<QRCodeCanvas
imageSettings={{
src: "/payment/alipay_f2f.svg",
width: 24,
height: 24,
excavate: true,
}}
size={208}
value={payment?.checkout_url || ""}
/>
<div className="flex gap-4">
<Button asChild>
<Link to="/subscribe">
{t("productList", "Product List")}
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/order">{t("orderList", "Order List")}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === "stripe" && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("waitingForPayment", "Waiting For Payment")}
</h3>
<p className="flex items-center font-bold text-3xl">
{countdownDisplay}
</p>
{payment.stripe && <StripePayment {...payment.stripe} />}
{/* <div className='flex gap-4'>
<Button asChild>
<Link to='/subscribe'>{t('productList', 'Product List')}</Link>
</Button>
<Button asChild variant='outline'>
<Link to='/order'>{t('orderList', 'Order List')}</Link>
</Button>
</div> */}
</div>
)}
{data?.status && [3, 4].includes(data?.status) && (
<div className="flex flex-col items-center gap-8 text-center">
<h3 className="font-bold text-2xl tracking-tight">
{t("orderClosed", "Order Closed")}
</h3>
<Icon className="text-7xl text-red-500" icon="mdi:cancel" />
<div className="flex gap-4">
<Button asChild>
<Link to="/subscribe">
{t("productList", "Product List")}
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/order">{t("orderList", "Order List")}</Link>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,372 @@
import {
CardCvcElement,
CardExpiryElement,
CardNumberElement,
Elements,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import {
loadStripe,
type PaymentIntentResult,
type StripeCardNumberElementOptions,
type StripeElementStyle,
} from "@stripe/stripe-js";
import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { useTheme } from "@workspace/ui/integrations/theme";
import { CheckCircle } from "lucide-react";
import { QRCodeCanvas } from "qrcode.react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
interface StripePaymentProps {
method: string;
client_secret: string;
publishable_key: string;
}
interface CardPaymentFormProps {
clientSecret: string;
onError: (message: string) => void;
}
const CardPaymentForm: React.FC<CardPaymentFormProps> = ({
clientSecret,
onError,
}) => {
const stripe = useStripe();
const { resolvedTheme } = useTheme();
const elements = useElements();
const [processing, setProcessing] = useState(false);
const [succeeded, setSucceeded] = useState(false);
const [errors, setErrors] = useState<{
cardNumber?: string;
cardExpiry?: string;
cardCvc?: string;
name?: string;
}>({});
const [cardholderName, setCardholderName] = useState("");
const { t } = useTranslation("payment");
const currentTheme = resolvedTheme;
const elementStyle: StripeElementStyle = {
base: {
fontSize: "16px",
color: currentTheme === "dark" ? "#fff" : "#000",
"::placeholder": {
color: "#aab7c4",
},
},
invalid: {
color: "#EF4444",
iconColor: "#EF4444",
},
};
const elementOptions: StripeCardNumberElementOptions = {
style: elementStyle,
showIcon: true,
};
const handleChange = (event: any, field: keyof typeof errors) => {
if (event.error) {
setErrors((prev) => ({ ...prev, [field]: event.error.message }));
} else {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!(stripe && elements)) {
onError(t("stripe.loading", "Loading Stripe..."));
return;
}
if (!cardholderName.trim()) {
setErrors((prev) => ({
...prev,
name: t("stripe.name_required", "Cardholder name is required"),
}));
return;
}
setProcessing(true);
const cardNumber = elements.getElement(CardNumberElement);
const cardExpiry = elements.getElement(CardExpiryElement);
const cardCvc = elements.getElement(CardCvcElement);
if (!(cardNumber && cardExpiry && cardCvc)) {
onError(t("stripe.element_error", "Please fill in all card details"));
setProcessing(false);
return;
}
const { error, paymentIntent } = await stripe.confirmCardPayment(
clientSecret,
{
payment_method: {
card: cardNumber,
billing_details: {
name: cardholderName,
},
},
}
);
if (error) {
onError(error.message || t("stripe.payment_failed", "Payment failed"));
setProcessing(false);
} else if (paymentIntent && paymentIntent.status === "succeeded") {
setSucceeded(true);
setProcessing(false);
} else {
onError(t("stripe.processing", "Processing payment..."));
setProcessing(false);
}
};
return (
<form onSubmit={handleSubmit}>
{succeeded ? (
<div className="py-6 text-center">
<div className="mb-4 flex justify-center">
<CheckCircle className="h-12 w-12 text-green-500" />
</div>
<p className="font-medium text-xl">
{t("stripe.success_title", "Payment Successful")}
</p>
<p className="mt-2 text-muted-foreground">
{t(
"stripe.success_message",
"Thank you for your purchase. Your order has been processed."
)}
</p>
</div>
) : (
<>
<div className="space-y-4">
{/* Cardholder Name */}
<div className="space-y-1">
<Label className="font-medium text-sm" htmlFor="cardholderName">
{t("stripe.card_name", "Cardholder Name")}
</Label>
<Input
className={errors.name ? "border-destructive" : ""}
id="cardholderName"
onChange={(e) => setCardholderName(e.target.value)}
placeholder={t("stripe.name_placeholder", "Full Name on Card")}
type="text"
value={cardholderName}
/>
{errors.name && (
<p className="text-destructive text-xs">{errors.name}</p>
)}
</div>
{/* Card Number */}
<div className="space-y-1">
<Label className="font-medium text-sm" htmlFor="cardNumber">
{t("stripe.card_number", "Card Number")}
</Label>
<div className="relative">
<div
className={`rounded-md border p-3 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${errors.cardNumber ? "border-red-500" : ""}`}
>
<CardNumberElement
id="cardNumber"
onChange={(e: any) => handleChange(e, "cardNumber")}
options={elementOptions}
/>
</div>
</div>
{errors.cardNumber && (
<p className="text-destructive text-xs">{errors.cardNumber}</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
{/* Expiry Date */}
<div className="space-y-1">
<Label className="font-medium text-sm" htmlFor="cardExpiry">
{t("stripe.expiry_date", "Expiry Date")}
</Label>
<div
className={`rounded-md border p-3 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${errors.cardExpiry ? "border-red-500" : ""}`}
>
<CardExpiryElement
id="cardExpiry"
onChange={(e: any) => handleChange(e, "cardExpiry")}
options={{ style: elementStyle }}
/>
</div>
{errors.cardExpiry && (
<p className="text-destructive text-xs">
{errors.cardExpiry}
</p>
)}
</div>
{/* Security Code */}
<div className="space-y-1">
<Label className="font-medium text-sm" htmlFor="cardCvc">
{t("stripe.security_code", "CVC")}
</Label>
<div
className={`rounded-md border p-3 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${errors.cardCvc ? "border-red-500" : ""}`}
>
<CardCvcElement
id="cardCvc"
onChange={(e: any) => handleChange(e, "cardCvc")}
options={{ style: elementStyle }}
/>
</div>
{errors.cardCvc && (
<p className="text-destructive text-xs">{errors.cardCvc}</p>
)}
</div>
</div>
</div>
<div className="mt-6 flex flex-col space-y-4">
<Button
className="w-full"
disabled={processing || !stripe || !elements}
type="submit"
>
{processing
? t("stripe.processing_button", "Processing...")
: t("stripe.pay_button", "Pay Now")}
</Button>
<p className="text-center text-muted-foreground text-xs">
{t("stripe.secure_notice", "Payments are secure and encrypted")}
</p>
</div>
</>
)}
</form>
);
};
const StripePayment: React.FC<StripePaymentProps> = ({
method,
client_secret,
publishable_key,
}) => {
const stripePromise = useMemo(
() => loadStripe(publishable_key),
[publishable_key]
);
return (
<Elements stripe={stripePromise}>
<CheckoutForm client_secret={client_secret} method={method} />
</Elements>
);
};
const CheckoutForm: React.FC<Omit<StripePaymentProps, "publishable_key">> = ({
client_secret,
method,
}) => {
const stripe = useStripe();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const { t } = useTranslation("payment");
const qrCodeMap: Record<string, string> = {
alipay: t("stripe.qrcode.alipay", "Scan with Alipay to pay"),
wechat_pay: t("stripe.qrcode.wechat_pay", "Scan with WeChat to pay"),
};
const handleError = useCallback((message: string) => {
setErrorMessage(message);
setIsSubmitted(false);
}, []);
const confirmPayment =
useCallback(async (): Promise<PaymentIntentResult | null> => {
if (!stripe) {
handleError(t("stripe.card.loading", "Loading Stripe..."));
return null;
}
if (method === "alipay") {
return await stripe.confirmAlipayPayment(
client_secret,
{ return_url: window.location.href },
{ handleActions: false }
);
}
if (method === "wechat_pay") {
return await stripe.confirmWechatPayPayment(
client_secret,
{
payment_method_options: { wechat_pay: { client: "web" } },
},
{ handleActions: false }
);
}
return null;
}, [client_secret, method, stripe, handleError, t]);
const autoSubmit = useCallback(async () => {
if (isSubmitted || method === "card") return;
setIsSubmitted(true);
try {
const result = await confirmPayment();
if (!result) return;
const { error, paymentIntent } = result;
if (error) return handleError(error.message!);
if (paymentIntent?.status === "requires_action") {
const nextAction = paymentIntent.next_action as any;
const qrUrl =
method === "alipay"
? nextAction?.alipay_handle_redirect?.url
: nextAction?.wechat_pay_display_qr_code?.image_url_svg;
setQrCodeUrl(qrUrl || null);
}
} catch (_error) {
handleError(t("stripe.error", "An error occurred"));
}
}, [confirmPayment, isSubmitted, handleError, method, t]);
useEffect(() => {
autoSubmit();
}, [autoSubmit]);
return method === "card" ? (
<div className="min-w-80 text-left">
<CardPaymentForm clientSecret={client_secret} onError={handleError} />
</div>
) : qrCodeUrl ? (
<>
<QRCodeCanvas
imageSettings={{
src: `/payment/${method}.svg`,
width: 24,
height: 24,
excavate: true,
}}
size={208}
value={qrCodeUrl}
/>
<p className="mt-4 text-center text-muted-foreground">
{qrCodeMap[method] || t(`qrcode.${method}`, `Scan with ${method}`)}
</p>
</>
) : (
errorMessage
);
};
export default StripePayment;
@@ -0,0 +1,107 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { updateUserPassword } from "@workspace/ui/services/user/user";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
export default function ChangePassword() {
const { t } = useTranslation("profile");
const FormSchema = z
.object({
password: z.string().min(6),
repeat_password: z.string(),
})
.refine((data) => data.password === data.repeat_password, {
message: t("accountSettings.passwordMismatch", "Passwords do not match"),
path: ["repeat_password"],
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
await updateUserPassword({ password: data.password });
toast.success(t("accountSettings.updateSuccess", "Update Successful"));
form.reset();
}
return (
<Card className="min-w-80">
<CardHeader>
<CardTitle className="flex items-center justify-between">
{t("accountSettings.accountSettings", "Password Settings")}
<Button form="password-form" size="sm" type="submit">
{t("accountSettings.updatePassword", "Update Password")}
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
className="space-y-4"
id="password-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"accountSettings.newPassword",
"New Password"
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repeat_password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder={t(
"accountSettings.repeatNewPassword",
"Repeat New Password"
)}
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,13 @@
import ChangePassword from "./change-password";
import NotifySettings from "./notify-settings";
import ThirdPartyAccounts from "./third-party-accounts";
export default function Profile() {
return (
<div className="flex flex-col gap-4 lg:flex-row lg:flex-wrap lg:*:flex-auto">
<ThirdPartyAccounts />
<NotifySettings />
<ChangePassword />
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@workspace/ui/components/form";
import { Switch } from "@workspace/ui/components/switch";
import { updateUserNotify } from "@workspace/ui/services/user/user";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
const FormSchema = z.object({
enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(),
});
export default function NotifySettings() {
const { t } = useTranslation("profile");
const { user, getUserInfo } = useGlobalStore();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
enable_balance_notify: user?.enable_balance_notify ?? false,
enable_login_notify: user?.enable_login_notify ?? false,
enable_subscribe_notify: user?.enable_subscribe_notify ?? false,
enable_trade_notify: user?.enable_trade_notify ?? false,
},
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
await updateUserNotify(data);
toast.success(t("notify.updateSuccess", "Update Successful"));
await getUserInfo();
}
return (
<Card className="min-w-80">
<CardHeader>
<CardTitle className="flex items-center justify-between">
{t("notify.notificationSettings", "Notification Settings")}
<Button form="notify-form" size="sm" type="submit">
{t("notify.save", "Save Changes")}
</Button>
</CardTitle>
</CardHeader>
<CardContent className="grid gap-6">
<Form {...form}>
<form
className="space-y-4"
id="notify-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="space-y-4">
{[
{
name: "enable_balance_notify",
label: t("notify.balanceChange", "Balance Change"),
},
{
name: "enable_login_notify",
label: t("notify.login", "Login"),
},
{
name: "enable_subscribe_notify",
label: t("notify.subscribe", "Subscribe"),
},
{
name: "enable_trade_notify",
label: t("notify.finance", "Finance"),
},
].map(({ name, label }) => (
<FormField
control={form.control}
key={name}
name={name as any}
render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-4">
<FormLabel className="text-muted-foreground">
{label}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,371 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
import { Icon } from "@workspace/ui/composed/icon";
import {
bindOAuth,
unbindOAuth,
updateBindEmail,
updateBindMobile,
} from "@workspace/ui/services/user/user";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import SendCode from "@/sections/auth/send-code";
import { useGlobalStore } from "@/stores/global";
function MobileBindDialog({
onSuccess,
children,
}: {
onSuccess: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation("profile");
const { common } = useGlobalStore();
const { enable_whitelist, whitelist } = common.auth.mobile;
const [open, setOpen] = useState(false);
const formSchema = z.object({
area_code: z.string().min(1, "Area code is required"),
mobile: z.string().min(5, "Phone number is required"),
code: z.string().min(4, "Verification code is required"),
});
type MobileBindFormValues = z.infer<typeof formSchema>;
const form = useForm<MobileBindFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
area_code: "1",
mobile: "",
code: "",
},
});
const onSubmit = async (values: MobileBindFormValues) => {
try {
await updateBindMobile(values);
toast.success(t("thirdParty.bindSuccess", "Successfully connected"));
onSuccess();
setOpen(false);
} catch (_error) {
toast.error(t("thirdParty.bindFailed", "Failed to connect"));
}
};
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{t("thirdParty.bindMobile", "Connect Mobile")}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="mobile"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex">
<FormField
control={form.control}
name="area_code"
render={({ field }) => (
<FormItem>
<FormControl>
<AreaCodeSelect
className="w-32 rounded-r-none border-r-0"
onChange={(value) => {
if (value.phone) {
form.setValue(field.name, value.phone);
}
}}
placeholder="Area code..."
simple
value={field.value}
whitelist={enable_whitelist ? whitelist : []}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Input
className="rounded-l-none"
placeholder="Enter your telephone..."
type="tel"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter code..."
type="text"
{...field}
/>
<SendCode
params={{
telephone_area_code: form.getValues().area_code,
telephone: form.getValues().mobile,
type: 1,
}}
type="phone"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit">
{t("thirdParty.confirm", "Confirm")}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
export default function ThirdPartyAccounts() {
const { t } = useTranslation("profile");
const { user, getUserInfo, common } = useGlobalStore();
const { oauth_methods } = common;
const accounts = [
{
id: "email",
icon: "logos:mailgun-icon",
name: "Email",
type: "Basic",
descriptionDefault: "Link your email address",
},
{
id: "mobile",
icon: "mdi:telephone",
name: "Mobile",
type: "Basic",
descriptionDefault: "Link your mobile number",
},
{
id: "telegram",
icon: "logos:telegram",
name: "Telegram",
type: "OAuth",
descriptionDefault: "Sign in with Telegram",
},
{
id: "apple",
icon: "uil:apple",
name: "Apple",
type: "OAuth",
descriptionDefault: "Sign in with Apple",
},
{
id: "google",
icon: "logos:google",
name: "Google",
type: "OAuth",
descriptionDefault: "Sign in with Google",
},
{
id: "facebook",
icon: "logos:facebook",
name: "Facebook",
type: "OAuth",
descriptionDefault: "Sign in with Facebook",
},
{
id: "github",
icon: "uil:github",
name: "GitHub",
type: "OAuth",
descriptionDefault: "Sign in with GitHub",
},
{
id: "device",
icon: "mdi:devices",
name: "Device",
type: "OAuth",
descriptionDefault: "Sign in with Device ID",
},
].filter((account) => oauth_methods?.includes(account.id));
const [editValues, setEditValues] = useState<Record<string, any>>({});
const handleBasicAccountUpdate = async (
account: (typeof accounts)[0],
value: string
) => {
if (account.id === "email") {
await updateBindEmail({ email: value });
await getUserInfo();
toast.success(t("thirdParty.updateSuccess", "Update Successful"));
}
};
const handleAccountAction = async (account: (typeof accounts)[number]) => {
const isBound = user?.auth_methods?.find(
(auth) => auth.auth_type === account.id
)?.auth_identifier;
if (isBound) {
await unbindOAuth({ method: account.id });
await getUserInfo();
} else {
const res = await bindOAuth({
method: account.id,
redirect: `${window.location.origin}/bind/${account.id}`,
});
if (res.data?.data?.redirect) {
window.location.href = res.data.data.redirect;
}
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t("thirdParty.title", "Connected Accounts")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{accounts.map((account) => {
const method = user?.auth_methods?.find(
(auth) => auth.auth_type === account.id
);
const isEditing = account.id === "email";
const currentValue =
method?.auth_identifier || editValues[account.id];
let displayValue = "";
switch (account.id) {
case "email":
displayValue = isEditing
? currentValue
: method?.auth_identifier || "";
break;
default:
displayValue =
method?.auth_identifier ||
t(
`thirdParty.${account.id}.description`,
account.descriptionDefault
);
}
return (
<div className="flex w-full flex-col gap-2" key={account.id}>
<span className="flex gap-3 font-medium">
<Icon className="size-6" icon={account.icon} />
{account.name}
</span>
<div className="flex items-center gap-2">
<Input
className="flex-1 truncate bg-muted"
disabled={!isEditing}
onChange={(e) =>
isEditing &&
setEditValues((prev) => ({
...prev,
[account.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter" && isEditing) {
handleBasicAccountUpdate(account, currentValue);
}
}}
value={displayValue}
/>
{account.id === "mobile" ? (
<MobileBindDialog onSuccess={getUserInfo}>
<Button
className="whitespace-nowrap"
variant={
method?.auth_identifier ? "outline" : "default"
}
>
{t(
method?.auth_identifier
? "thirdParty.update"
: "thirdParty.bind",
method?.auth_identifier ? "Update" : "Connect"
)}
</Button>
</MobileBindDialog>
) : (
<Button
className="whitespace-nowrap"
onClick={() =>
isEditing
? handleBasicAccountUpdate(account, currentValue)
: handleAccountAction(account)
}
variant={method?.auth_identifier ? "outline" : "default"}
>
{t(
isEditing
? "thirdParty.save"
: method?.auth_identifier
? "thirdParty.unbind"
: "thirdParty.bind",
isEditing
? "Save"
: method?.auth_identifier
? "Disconnect"
: "Connect"
)}
</Button>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,56 @@
"use client";
import { Link, useLocation } from "@tanstack/react-router";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@workspace/ui/components/sidebar";
import { Icon } from "@workspace/ui/composed/icon";
import { useTranslation } from "react-i18next";
import { useNavs } from "@/layout/navs";
export function SidebarLeft({
...props
}: React.ComponentProps<typeof Sidebar>) {
const { t } = useTranslation("menu");
const location = useLocation();
const navs = useNavs();
return (
<Sidebar collapsible="none" side="left" {...props}>
<SidebarContent>
<SidebarMenu>
{navs.map((nav) => (
<SidebarGroup key={nav.title}>
{nav.items && (
<SidebarGroupLabel>{t(nav.title)}</SidebarGroupLabel>
)}
<SidebarGroupContent>
<SidebarMenu>
{(nav.items || [nav]).map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={item.url === location.pathname}
tooltip={t(item.title)}
>
<Link to={item.url || "/"}>
{item.icon && <Icon icon={item.icon} />}
<span>{t(item.title)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarMenu>
</SidebarContent>
</Sidebar>
);
}
@@ -0,0 +1,90 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import { Sidebar, SidebarContent } from "@workspace/ui/components/sidebar";
import { Icon } from "@workspace/ui/composed/icon";
import { isBrowser } from "@workspace/ui/utils/index";
import CopyToClipboard from "react-copy-to-clipboard";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Display } from "@/components/display";
import Recharge from "@/sections/subscribe/recharge";
import { useGlobalStore } from "@/stores/global";
export function SidebarRight({
...props
}: React.ComponentProps<typeof Sidebar>) {
const { user } = useGlobalStore();
const { t } = useTranslation("layout");
return (
<Sidebar collapsible="none" side="right" {...props}>
<SidebarContent className="*:gap-0 *:py-0">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-3 pb-2">
<CardTitle className="font-medium text-sm">
{t("accountBalance", "Account Balance")}
</CardTitle>
<Recharge className="p-0" variant="link" />
</CardHeader>
<CardContent className="p-3 font-bold text-2xl">
<Display type="currency" value={user?.balance} />
</CardContent>
</Card>
<Card>
<CardHeader className="space-y-0 p-3 pb-2">
<CardTitle className="font-medium text-sm">
{t("giftAmount", "Gift Amount")}
</CardTitle>
</CardHeader>
<CardContent className="p-3 font-bold text-2xl">
<Display type="currency" value={user?.gift_amount} />
</CardContent>
</Card>
<Card>
<CardHeader className="space-y-0 p-3 pb-2">
<CardTitle className="font-medium text-sm">
{t("commission", "Commission")}
</CardTitle>
</CardHeader>
<CardContent className="p-3 font-bold text-2xl">
<Display type="currency" value={user?.commission} />
</CardContent>
</Card>
{user?.refer_code && (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-3 pb-2">
<CardTitle className="font-medium text-sm">
{t("inviteCode", "Invite Code")}
</CardTitle>
<CopyToClipboard
onCopy={(_text: string, result: boolean) => {
if (result) {
toast.success(t("copySuccess", "Copy Success"));
}
}}
text={`${isBrowser() && location?.origin}/auth?invite=${user?.refer_code}`}
>
<Button className="size-5 p-0" variant="ghost">
<Icon
className="text-2xl text-primary"
icon="mdi:content-copy"
/>
</Button>
</CopyToClipboard>
</CardHeader>
<CardContent className="truncate p-3 font-bold">
{user?.refer_code}
</CardContent>
</Card>
)}
</SidebarContent>
</Sidebar>
);
}
@@ -0,0 +1,428 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@workspace/ui/components/drawer";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import { Textarea } from "@workspace/ui/components/textarea";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import Empty from "@workspace/ui/composed/empty";
import { Icon } from "@workspace/ui/composed/icon";
import {
ProList,
type ProListActions,
} from "@workspace/ui/composed/pro-list/pro-list";
import { cn } from "@workspace/ui/lib/utils";
import {
createUserTicket,
createUserTicketFollow,
getUserTicketDetails,
getUserTicketList,
updateUserTicketStatus,
} from "@workspace/ui/services/user/ticket";
import { formatDate } from "@workspace/ui/utils/formatting";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export default function Ticket() {
const { t } = useTranslation("ticket");
const statusMap: Record<number, string> = {
0: t("status.0", "Status"),
1: t("status.1", "Pending Reply"),
2: t("status.2", "Pending Follow-up"),
3: t("status.3", "Resolved"),
4: t("status.4", "Closed"),
};
const [ticketId, setTicketId] = useState<any>(null);
const [message, setMessage] = useState("");
const { data: ticket, refetch: refetchTicket } = useQuery({
queryKey: ["getUserTicketDetails", ticketId],
queryFn: async () => {
const { data } = await getUserTicketDetails({ id: ticketId });
return data.data as API.Ticket;
},
enabled: !!ticketId,
refetchInterval: 5000,
});
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.children[1]?.scrollTo({
top: scrollRef.current.children[1].scrollHeight,
behavior: "smooth",
});
}
}, 66);
}, [ticket?.follow?.length]);
const ref = useRef<ProListActions>(null);
const [create, setCreate] =
useState<Partial<API.CreateUserTicketRequest & { open: boolean }>>();
return (
<>
<ProList<API.Ticket, { status: number }>
action={ref}
empty={<Empty />}
header={{
title: t("ticketList", "Ticket List"),
toolbar: (
<Dialog
onOpenChange={(open) => setCreate({ open })}
open={create?.open}
>
<DialogTrigger asChild>
<Button size="sm">{t("createTicket", "Create Ticket")}</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{t("createTicket", "Create Ticket")}
</DialogTitle>
<DialogDescription>
{t("createTicketDescription", "Create Ticket Description")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<Label htmlFor="title">{t("title", "Title")}</Label>
<Input
defaultValue={create?.title}
id="title"
onChange={(e) =>
setCreate({ ...create, title: e.target.value! })
}
/>
<Label htmlFor="content">
{t("description", "Description")}
</Label>
<Textarea
defaultValue={create?.description}
id="content"
onChange={(e) =>
setCreate({ ...create, description: e.target.value! })
}
/>
</div>
<DialogFooter>
<Button
disabled={!(create?.title && create?.description)}
onClick={async () => {
await createUserTicket({
title: create!.title!,
description: create!.description!,
});
ref.current?.refresh();
toast.success(t("createSuccess", "Create Success"));
setCreate({ open: false });
}}
>
{t("submit", "Submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
),
}}
params={[
{
key: "search",
},
{
key: "status",
placeholder: t("status.0", "Status"),
options: [
{
label: t("close", "Close"),
value: "4",
},
],
},
]}
renderItem={(item) => (
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 bg-muted/50 p-3">
<CardTitle>
<span
className={cn(
"flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50",
{
"before:bg-yellow-500 before:ring-yellow-500":
item.status === 1,
"before:bg-rose-500 before:ring-rose-500":
item.status === 2,
"before:bg-green-500 before:ring-green-500":
item.status === 3,
"before:bg-zinc-500 before:ring-zinc-500":
item.status === 4,
}
)}
>
{statusMap[item.status] ||
t(`status.${item.status}`, "Unknown Status")}
</span>
</CardTitle>
<CardDescription className="flex gap-2">
{item.status !== 4 ? (
<>
<Button
key="reply"
onClick={() => setTicketId(item.id)}
size="sm"
>
{t("reply", "Reply")}
</Button>
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"closeWarning",
"Are you sure you want to close this ticket?"
)}
key="close"
onConfirm={async () => {
await updateUserTicketStatus({
id: item.id,
status: 4,
});
toast.success(t("closeSuccess", "Close Success"));
ref.current?.refresh();
}}
title={t("confirmClose", "Confirm Close")}
trigger={
<Button size="sm" variant="destructive">
{t("close", "Close")}
</Button>
}
/>
</>
) : (
<Button
key="check"
onClick={() => setTicketId(item.id)}
size="sm"
>
{t("check", "Check")}
</Button>
)}
</CardDescription>
</CardHeader>
<CardContent className="p-3 text-sm">
<ul className="grid gap-3 *:flex *:flex-col lg:grid-cols-3">
<li>
<span className="text-muted-foreground">
{t("title", "Title")}
</span>
<span> {item.title}</span>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("description", "Description")}
</span>
<time>{item.description}</time>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("updatedAt", "Updated At")}
</span>
<time>{formatDate(item.updated_at)}</time>
</li>
</ul>
</CardContent>
</Card>
)}
request={async (pagination, filters) => {
const { data } = await getUserTicketList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
<Drawer
onOpenChange={(open) => {
if (!open) setTicketId(null);
}}
open={!!ticketId}
>
<DrawerContent className="container mx-auto h-screen">
<DrawerHeader className="border-b text-left">
<DrawerTitle>{ticket?.title}</DrawerTitle>
<DrawerDescription className="line-clamp-3">
{ticket?.description}
</DrawerDescription>
</DrawerHeader>
<ScrollArea className="h-full overflow-hidden" ref={scrollRef}>
<div className="flex flex-col gap-4 p-4">
{ticket?.follow?.map((item) => (
<div
className={cn("flex items-center gap-4", {
"flex-row-reverse": item.from !== "System",
})}
key={item.id}
>
<div
className={cn("flex flex-col gap-1", {
"items-end": item.from !== "System",
})}
>
<p className="text-muted-foreground text-sm">
{formatDate(item.created_at)}
</p>
<p
className={cn(
"w-fit rounded-lg bg-accent p-2 font-medium",
{
"bg-primary text-primary-foreground":
item.from !== "System",
}
)}
>
{item.type === 1 && item.content}
{item.type === 2 && (
<img
alt="ticket attachment"
className="!size-auto object-cover"
height={300}
src={item.content!}
width={300}
/>
)}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
{ticket?.status !== 4 && (
<DrawerFooter>
<form
className="flex w-full flex-row items-center gap-2"
onSubmit={async (event) => {
event.preventDefault();
if (message) {
await createUserTicketFollow({
ticket_id: ticketId,
from: "User",
type: 1,
content: message,
});
refetchTicket();
setMessage("");
}
}}
>
<Button className="p-0" type="button" variant="outline">
<Label className="p-2" htmlFor="picture">
<Icon className="text-2xl" icon="uil:image-upload" />
</Label>
<Input
accept="image/*"
className="hidden"
id="picture"
onChange={(event) => {
const file = event.target.files?.[0];
if (file?.type.startsWith("image/")) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const maxWidth = 300;
const maxHeight = 300;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round(
(maxWidth / width) * height
);
width = maxWidth;
}
} else if (height > maxHeight) {
width = Math.round((maxHeight / height) * width);
height = maxHeight;
}
canvas.width = width;
canvas.height = height;
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob!);
reader.onloadend = async () => {
await createUserTicketFollow({
ticket_id: ticketId,
from: "User",
type: 2,
content: reader.result as string,
});
refetchTicket();
};
},
"image/webp",
0.8
);
};
};
}
}}
type="file"
/>
</Button>
<Input
onChange={(e) => setMessage(e.target.value)}
placeholder={t("inputPlaceholder", "Input Placeholder")}
value={message}
/>
<Button disabled={!message} type="submit">
<Icon icon="uil:navigator" />
</Button>
</form>
</DrawerFooter>
)}
</DrawerContent>
</Drawer>
</>
);
}
@@ -0,0 +1,150 @@
"use client";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
ProList,
type ProListActions,
} from "@workspace/ui/composed/pro-list/pro-list";
import { queryUserBalanceLog } from "@workspace/ui/services/user/user";
import { formatDate } from "@workspace/ui/utils/formatting";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Display } from "@/components/display";
import Recharge from "@/sections/subscribe/recharge";
import { useGlobalStore } from "@/stores/global";
export default function Wallet() {
const { t } = useTranslation("wallet");
const typeMap: Record<number, string> = {
0: t("type.0", "Type"),
1: t("type.1", "Recharge"),
2: t("type.2", "Withdrawal"),
3: t("type.3", "Purchase"),
4: t("type.4", "Refund"),
5: t("type.5", "Reward"),
6: t("type.6", "Commission"),
231: t("type.231", "Auto Reset"),
232: t("type.232", "Advance Reset"),
233: t("type.233", "Paid Reset"),
321: t("type.321", "Recharge"),
322: t("type.322", "Withdraw"),
323: t("type.323", "Payment"),
324: t("type.324", "Refund"),
325: t("type.325", "Reward"),
326: t("type.326", "Admin Adjust"),
331: t("type.331", "Purchase"),
332: t("type.332", "Renewal"),
333: t("type.333", "Refund"),
334: t("type.334", "Withdraw"),
335: t("type.335", "Admin Adjust"),
341: t("type.341", "Increase"),
342: t("type.342", "Reduce"),
};
const { user } = useGlobalStore();
const ref = useRef<ProListActions>(null);
const totalAssets =
(user?.balance || 0) + (user?.commission || 0) + (user?.gift_amount || 0);
return (
<>
<Card>
<CardContent>
<h2 className="mb-4 font-bold text-2xl text-foreground">
{t("assetOverview", "Asset Overview")}
</h2>
<div className="mb-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">
{t("totalAssets", "Total Assets")}
</p>
<p className="font-bold text-3xl">
<Display type="currency" value={totalAssets} />
</p>
</div>
<Recharge />
</div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="rounded-lg bg-secondary p-4 shadow-sm transition-all duration-300 hover:shadow-md">
<p className="font-medium text-secondary-foreground text-sm opacity-80">
{t("balance", "Balance")}
</p>
<p className="font-bold text-2xl text-secondary-foreground">
<Display type="currency" value={user?.balance} />
</p>
</div>
<div className="rounded-lg bg-secondary p-4 shadow-sm transition-all duration-300 hover:shadow-md">
<p className="font-medium text-secondary-foreground text-sm opacity-80">
{t("giftAmount", "Gift Amount")}
</p>
<p className="font-bold text-2xl text-secondary-foreground">
<Display type="currency" value={user?.gift_amount} />
</p>
</div>
<div className="rounded-lg bg-secondary p-4 shadow-sm transition-all duration-300 hover:shadow-md">
<p className="font-medium text-secondary-foreground text-sm opacity-80">
{t("commission", "Commission")}
</p>
<p className="font-bold text-2xl text-secondary-foreground">
<Display type="currency" value={user?.commission} />
</p>
</div>
</div>
</CardContent>
</Card>
<ProList<API.BalanceLog, Record<string, unknown>>
action={ref}
renderItem={(item) => (
<Card className="overflow-hidden">
<CardContent className="text-sm">
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-4">
<li className="font-semibold">
<span className="text-muted-foreground">
{t("createdAt", "Created At")}
</span>
<time>{formatDate(item.timestamp)}</time>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("type.0", "Type")}
</span>
<span>
{typeMap[item.type] ||
t(`type.${item.type}`, "Unknown Type")}
</span>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("amount", "Amount")}
</span>
<span>
<Display type="currency" value={item.amount} />
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("balance", "Balance")}
</span>
<span>
<Display type="currency" value={item.balance} />
</span>
</li>
</ul>
</CardContent>
</Card>
)}
request={async (pagination, filter) => {
const response = await queryUserBalanceLog({
...pagination,
...filter,
});
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
/>
</>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { queryUserInfo } from "@workspace/ui/services/user/user";
import { isBrowser } from "@workspace/ui/utils/index";
import { create } from "zustand";
export interface GlobalStore {
common: API.GetGlobalConfigResponse;
user?: API.User;
setCommon: (common: Partial<API.GetGlobalConfigResponse>) => void;
setUser: (user?: API.User) => void;
getUserInfo: () => Promise<void>;
getUserSubscribe: (uuid: string, type?: string) => string[];
getAppSubLink: (url: string, schema?: string) => string;
}
/**
* Extracts the full domain or root domain from a URL.
*
* @param url - The URL to extract the domain from.
* @param extractRoot - If true, extracts the root domain (e.g., example.com). If false, extracts the full domain (e.g., sub.example.com).
* @returns The extracted domain or root domain, or null if the URL is invalid.
*/
export function extractDomain(url: string, extractRoot = true): string | null {
try {
const { hostname } = new URL(url);
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
return hostname;
}
const domainParts = hostname.split(".").filter(Boolean);
if (extractRoot && domainParts.length > 2) {
return domainParts.slice(-2).join(".");
}
return hostname;
} catch (error) {
console.error("Invalid URL:", error);
return null;
}
}
export const useGlobalStore = create<GlobalStore>((set, get) => ({
common: {
site: {
host: "",
site_name: "",
site_desc: "",
site_logo: "",
keywords: "",
custom_html: "",
custom_data: "",
},
verify: {
turnstile_site_key: "",
enable_login_verify: false,
enable_register_verify: false,
enable_reset_password_verify: false,
},
auth: {
mobile: {
enable: false,
enable_whitelist: false,
whitelist: [],
},
email: {
enable: false,
enable_verify: false,
enable_domain_suffix: false,
domain_suffix_list: "",
},
register: {
stop_register: false,
enable_ip_register_limit: false,
ip_register_limit: 0,
ip_register_limit_duration: 0,
},
device: {
enable: false,
show_ads: false,
enable_security: false,
only_real_device: false,
},
},
invite: {
forced_invite: false,
referral_percentage: 0,
only_first_purchase: false,
},
currency: {
currency_unit: "USD",
currency_symbol: "$",
},
subscribe: {
single_model: false,
subscribe_path: "",
subscribe_domain: "",
pan_domain: false,
user_agent_limit: false,
user_agent_list: "",
},
verify_code: {
verify_code_expire_time: 5,
verify_code_limit: 15,
verify_code_interval: 60,
},
oauth_methods: [],
web_ad: false,
},
user: undefined,
setCommon: (common) =>
set((state) => ({
common: {
...state.common,
...common,
},
})),
setUser: (user) => set({ user }),
getUserInfo: async () => {
try {
const { data } = await queryUserInfo();
set({ user: data.data });
} catch (error) {
console.error("Failed to refresh user:", error);
}
},
getUserSubscribe: (uuid: string, type?: string) => {
const { pan_domain, subscribe_domain, subscribe_path } =
get().common.subscribe || {};
const domains = subscribe_domain
? subscribe_domain.split("\n")
: [extractDomain(window.location.origin, pan_domain)];
return domains.map((domain) => {
if (pan_domain) {
if (type) return `https://${uuid}.${type}.${domain}`;
return `https://${uuid}.${domain}`;
}
if (type)
return `https://${domain}${subscribe_path}?token=${uuid}&type=${type}`;
return `https://${domain}${subscribe_path}?token=${uuid}`;
});
},
getAppSubLink: (url: string, schema?: string) => {
const name = get().common?.site?.site_name || "";
if (!schema) return "url";
try {
let result = schema.replace(/\${url}/g, url).replace(/\${name}/g, name);
const maxLoop = 10;
let prev: string;
let loop = 0;
do {
prev = result;
result = result.replace(
/\${encodeURIComponent\(JSON\.stringify\(([^)]+)\)\)}/g,
(match, expr) => {
try {
const processedExpr = expr
.replace(/url/g, `"${url}"`)
.replace(/name/g, `"${name}"`);
if (processedExpr.includes("server_remote")) {
const serverRemoteValue = `${url}, tag=${name}`;
return encodeURIComponent(
JSON.stringify({ server_remote: [serverRemoteValue] })
);
}
const obj = eval(`(${processedExpr})`);
return encodeURIComponent(JSON.stringify(obj));
} catch {
return match;
}
}
);
result = result.replace(
/\${encodeURIComponent\(([^)]+)\)}/g,
(match, expr) => {
if (expr === "url") return encodeURIComponent(url);
if (expr === "name") return encodeURIComponent(name);
try {
return encodeURIComponent(expr);
} catch {
return match;
}
}
);
result = result.replace(
/\${window\.btoa\(([^)]+)\)}/g,
(match, expr) => {
const btoa = isBrowser() ? window.btoa : (str: string) => str;
if (expr === "url") return btoa(url);
if (expr === "name") return btoa(name);
try {
return btoa(expr);
} catch {
return match;
}
}
);
result = result.replace(
/\${JSON\.stringify\(([^}]+)\)}/g,
(match, expr) => {
try {
const processedExpr = expr
.replace(/url/g, `"${url}"`)
.replace(/name/g, `"${name}"`);
if (processedExpr.includes("server_remote")) {
const serverRemoteValue = `${url}, tag=${name}`;
return JSON.stringify({ server_remote: [serverRemoteValue] });
}
const result = eval(`(${processedExpr})`);
return JSON.stringify(result);
} catch {
return match;
}
}
);
loop++;
} while (result !== prev && loop < maxLoop);
return result;
} catch (_error) {
return "";
}
},
}));
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="@workspace/ui/services/user/typings" />
/// <reference types="@workspace/ui/services/common/typings" />
+60
View File
@@ -0,0 +1,60 @@
import { removeCookie, setCookie } from "@workspace/ui/lib/cookies";
import { isBrowser } from "@workspace/ui/utils/index";
export function getPlatform(): string {
if (typeof window === "undefined") return "unknown";
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes("win")) return "windows";
if (userAgent.includes("mac")) return "macos";
if (userAgent.includes("linux")) return "linux";
if (userAgent.includes("android")) return "android";
if (userAgent.includes("iphone") || userAgent.includes("ipad")) return "ios";
return "unknown";
}
export function differenceInDays(date1: Date, date2: Date): number {
const diffTime = Math.abs(date1.getTime() - date2.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
export function formatDate(timestamp: number): string {
if (!timestamp) return "";
return new Date(timestamp * 1000).toLocaleDateString();
}
export function setAuthorization(token: string): void {
setCookie("Authorization", token);
}
export function getRedirectUrl(): string {
if (typeof window === "undefined") return "/dashboard";
const params = new URLSearchParams(window.location.search);
const redirect = params.get("redirect");
return redirect?.startsWith("/") ? redirect : "/dashboard";
}
export function setRedirectUrl(value?: string) {
if (value) {
sessionStorage.setItem("redirect-url", value);
}
}
export function Logout() {
if (!isBrowser()) return;
removeCookie("Authorization");
const pathname = location.pathname;
if (
!(
["", "/", "/auth", "/tos", "/privacy-policy"].includes(pathname) ||
pathname.startsWith("/purchasing") ||
pathname.startsWith("/oauth/")
)
) {
setRedirectUrl(location.pathname);
location.href = "/auth";
}
}