🎉 feat: initialization
This commit is contained in:
@@ -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?")}
|
||||
<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?")}
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={() => {
|
||||
setInitialValues(undefined);
|
||||
onSwitchForm("login");
|
||||
}}
|
||||
variant="link"
|
||||
>
|
||||
{t("reset.switchToLogin", "Login")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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?")}
|
||||
<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?")}
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={() => {
|
||||
onSwitchForm("login");
|
||||
}}
|
||||
variant="link"
|
||||
>
|
||||
{t("reset.switchToLogin", "Login")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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) =>
|
||||
` ? imgUrl : `${prefix}${imgUrl}`})`
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user