🎉 chore(init): project initialization

This commit is contained in:
web@ppanel
2024-11-14 01:22:43 +07:00
commit 829edfa824
479 changed files with 61413 additions and 0 deletions
@@ -0,0 +1,40 @@
'use client';
import { queryAnnouncement } from '@/services/user/announcement';
import Empty from '@repo/ui/empty';
import { Markdown } from '@repo/ui/markdown';
import { formatDate } from '@repo/ui/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useQuery } from '@tanstack/react-query';
export default function Page() {
const { data } = useQuery({
queryKey: ['queryAnnouncement'],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: 1,
size: 20,
});
return data.data?.announcements || [];
},
});
return (
<div className='flex flex-col gap-5'>
{data?.length ? (
data.map((item) => (
<Card key={item.id}>
<CardHeader>
<CardTitle>{item.title}</CardTitle>
<CardDescription>{formatDate(item.updated_at)}</CardDescription>
</CardHeader>
<CardContent>
<Markdown>{item.content}</Markdown>
</CardContent>
</Card>
))
) : (
<Empty />
)}
</div>
);
}
@@ -0,0 +1,36 @@
'use client';
import { queryAnnouncement } from '@/services/user/announcement';
import { Icon } from '@iconify/react';
import Empty from '@repo/ui/empty';
import { Markdown } from '@repo/ui/markdown';
import { Card } from '@shadcn/ui/card';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
export default function Announcement() {
const t = useTranslations('dashboard');
const { data } = useQuery({
queryKey: ['queryAnnouncement', 1],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: 1,
size: 1,
});
return (data.data?.announcements?.[0] as API.AnnouncementDetails) || {};
},
});
return (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:bell' className='size-5' />
{t('latestAnnouncement')}
</h2>
<Card className='p-6'>
{data?.content ? <Markdown>{data?.content}</Markdown> : <Empty />}
</Card>
</>
);
}
@@ -0,0 +1,265 @@
'use client';
import { Display } from '@/components/display';
import { queryApplicationConfig } from '@/services/user/subscribe';
import { queryUserSubscribe } from '@/services/user/user';
import { Icon } from '@iconify/react';
import { getNextResetDate, isBrowser } from '@repo/ui/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@shadcn/ui/accordion';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@shadcn/ui/alert-dialog';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { differenceInDays } from '@shadcn/ui/lib/date-fns';
import { toast } from '@shadcn/ui/lib/sonner';
import { Separator } from '@shadcn/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useState } from 'react';
import useGlobalStore from '@/config/use-global';
import Renewal from '../order/renewal';
import ResetTraffic from '../order/reset-traffic';
import Subscribe from '../subscribe/page';
import Announcement from './announcemnet';
export default function Page() {
const t = useTranslations('dashboard');
const { getUserSubscribe, getAppSubLink } = useGlobalStore();
const [protocol, setProtocol] = useState('');
const { data: userSubscribe = [] } = useQuery({
queryKey: ['queryUserSubscribe'],
queryFn: async () => {
const { data } = await queryUserSubscribe();
return data.data?.list || [];
},
});
const { data: application } = useQuery({
queryKey: ['queryApplicationConfig'],
queryFn: async () => {
const { data } = await queryApplicationConfig();
return data.data as API.QueryApplicationConfigResponse;
},
});
const [platform, setPlatform] = useState<keyof API.QueryApplicationConfigResponse>('windows');
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t('copySuccess'));
} catch {
toast.error(t('copyFailure'));
}
};
return (
<div className='flex min-h-[calc(100vh-64px-58px-32px-114px)] w-full flex-col gap-4 overflow-hidden'>
<Announcement />
{userSubscribe.length ? (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:servers' className='size-5' />
{t('mySubscriptions')}
</h2>
<div className='flex flex-wrap justify-between gap-4'>
<Tabs
value={platform}
onValueChange={(value) =>
setPlatform(value as keyof API.QueryApplicationConfigResponse)
}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{application &&
Object.keys(application)?.map((item) => (
<TabsTrigger value={item} key={item} className='px-1 uppercase lg:px-3'>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Tabs
value={protocol}
onValueChange={setProtocol}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{['all', 'ss', 'vmess', 'vless', 'trojan'].map((item) => (
<TabsTrigger
value={item === 'all' ? '' : item}
key={item}
className='px-1 uppercase lg:px-3'
>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{userSubscribe.map((item) => (
<Card key={item.id}>
<CardHeader className='flex flex-row flex-wrap items-center justify-between gap-2 space-y-0'>
<CardTitle className='font-medium'>{item.subscribe.name}</CardTitle>
<div className='flex gap-2'>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size='sm' variant='destructive'>
{t('resetSubscription')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('prompt')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirmResetSubscription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => toast.success(t('resetSuccess'))}>
{t('confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResetTraffic
id={item.subscribe_id}
mark={item.mark}
replacement={item.subscribe.replacement}
/>
<Renewal mark={item.mark} subscribe={item.subscribe} />
</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')}</span>
<span className='text-2xl font-bold'>
<Display
type='traffic'
value={item.upload + item.download}
unlimited={!item.traffic}
/>
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('totalTraffic')}</span>
<span className='text-2xl font-bold'>
<Display type='traffic' value={item.traffic} unlimited={!item.traffic} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('nextResetDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(getNextResetDate(item.start_time), new Date()) ||
t('unknown')}
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('expirationDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(new Date(item.expire_time), new Date()) || t('unknown')}
</span>
</li>
</ul>
<Separator className='mt-4' />
<Accordion type='single' collapsible defaultValue='0' className='w-full'>
{getUserSubscribe(item.mark, 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='text-sm font-medium'>
{t('subscriptionUrl')} {index + 1}
</CardTitle>
<span
className='text-primary hover:bg-accent mr-4 flex cursor-pointer rounded p-2 text-sm'
onClick={(e) => {
e.stopPropagation();
handleCopy(url);
}}
>
<Icon icon='uil:copy' className='mr-2 size-5' />
{t('copy')}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className='grid grid-cols-3 gap-4 lg:grid-cols-4 xl:grid-cols-7'>
{application &&
application[platform]?.map((app) => (
<div
key={app.name}
className='text-muted-foreground flex size-full flex-col items-center justify-between gap-2 text-xs'
>
<span>{app.name}</span>
{app.icon && (
<Image src={app.icon} alt={app.name} width={50} height={50} />
)}
<div className='flex'>
<Button size='sm' variant='secondary' className='px-1.5' asChild>
<Link href={app.url!}>{t('download')}</Link>
</Button>
<Button
size='sm'
onClick={() => {
handleCopy(url);
const href = getAppSubLink(app.subscribe_type, url);
if (isBrowser() && href) {
window.location.href = href;
} else {
toast.info(t('manualImportMessage'));
}
}}
className='p-2'
>
{t('import')}
</Button>
</div>
</div>
))}
<div className='text-muted-foreground hidden size-full flex-col items-center justify-between gap-2 text-sm lg:flex'>
<span>{t('qrCode')}</span>
<QRCodeCanvas
value={url}
size={80}
bgColor='transparent'
fgColor='rgb(59, 130, 246)'
/>
<span className='text-center'>{t('scanToSubscribe')}</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
))}
</>
) : (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:shop' className='size-5' />
{t('purchaseSubscription')}
</h2>
<Subscribe />
</>
)}
</div>
);
}
@@ -0,0 +1,110 @@
'use client';
import { ProList } from '@/components/pro-list';
import { queryDocumentDetail, queryDocumentList } from '@/services/user/document';
import { Markdown } from '@repo/ui/markdown';
import { formatDate } from '@repo/ui/utils';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Fragment, useState } from 'react';
export default function Page() {
const t = useTranslations('document');
const [tags, setTags] = useState<string[]>([]);
const [selected, setSelected] = useState<number>();
const { data } = useQuery({
enabled: !!selected,
queryKey: ['queryDocumentDetail', selected],
queryFn: async () => {
const { data } = await queryDocumentDetail({
id: selected!,
});
return data.data;
},
});
return (
<Fragment>
{selected ? (
<Card>
<CardHeader className='pb-2'>
<div className='flex items-center justify-between'>
<Button variant='outline' onClick={() => setSelected(undefined)}>
<ChevronLeft className='size-4' />
{t('back')}
</Button>
<CardTitle className='font-medium'>{data?.title}</CardTitle>
<CardDescription>{formatDate(data?.updated_at)}</CardDescription>
</div>
</CardHeader>
<CardContent>
<Markdown>{data?.content || ''}</Markdown>
</CardContent>
</Card>
) : (
<ProList<API.DocumentItem, { tag: string }>
params={[
{
key: 'tag',
placeholder: t('category'),
options: tags.map((item) => ({
label: item,
value: item,
})),
},
]}
request={async (_, filter) => {
const response = await queryDocumentList();
const list = response.data.data?.list || [];
setTags(
Array.from(new Set(list.reduce((acc: string[], item) => acc.concat(item.tags), []))),
);
const filterList = list.filter((item) =>
filter.tag ? item.tags.includes(filter.tag) : true,
);
return {
list: filterList,
total: filterList.length || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
<CardTitle>{item.title}</CardTitle>
<CardDescription>
<Button
size='sm'
onClick={() => {
setSelected(item.id);
}}
>
{t('read')}
</Button>
</CardDescription>
</CardHeader>
<CardContent className='p-3 text-sm'>
<ul className='grid gap-3 *:flex *:flex-col lg:grid-cols-2'>
<li>
<span className='text-muted-foreground'>{t('tags')}</span>
<span>{item.tags.join(', ')}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('updatedAt')}</span>
<time>{formatDate(item.updated_at)}</time>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
)}
</Fragment>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { ScrollArea } from '@shadcn/ui/scroll-area';
import { SidebarInset, SidebarProvider } from '@shadcn/ui/sidebar';
import { SidebarLeft } from './sidebar-left';
import { SidebarRight } from './sidebar-right';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider className='container'>
<SidebarLeft className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
<SidebarInset className='flex-grow overflow-hidden'>
{/* <Header /> */}
<div className='flex h-[calc(100vh-56px)] flex-1 flex-col gap-4 p-4'>
<ScrollArea className='h-full flex-grow overflow-hidden'>{children}</ScrollArea>
</div>
</SidebarInset>
<SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
</SidebarProvider>
);
}
@@ -0,0 +1,96 @@
'use client';
import { Display } from '@/components/display';
import { ProList, ProListActions } from '@/components/pro-list';
import { closeOrder, queryOrderList } from '@/services/user/order';
import { formatDate } from '@repo/ui/utils';
import { Button, buttonVariants } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useRef } from 'react';
export default function Page() {
const t = useTranslations('order');
const ref = useRef<ProListActions>(null);
return (
<ProList<API.OrderDetails, Record<string, unknown>>
action={ref}
request={async (pagination, filter) => {
const response = await queryOrderList({ ...pagination, ...filter });
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
<CardTitle>
{t('orderNo')}
<p className='text-sm'>{item.orderNo}</p>
</CardTitle>
<CardDescription className='flex gap-2'>
{item.status === 1 ? (
<>
<Link
key='payment'
href={`/payment?order_no=${item.orderNo}`}
className={buttonVariants({ size: 'sm' })}
>
{t('payment')}
</Link>
<Button
key='cancel'
size='sm'
variant='destructive'
onClick={async () => {
await closeOrder({ orderNo: item.orderNo });
ref.current?.refresh();
}}
>
{t('cancel')}
</Button>
</>
) : (
<Link
key='detail'
href={`/payment?order_no=${item.orderNo}`}
className={buttonVariants({ size: 'sm' })}
>
{t('detail')}
</Link>
)}
</CardDescription>
</CardHeader>
<CardContent className='p-3 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')}</span>
<span>{item.subscribe.name || t(`type.${item.type}`)}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('paymentAmount')}</span>
<span>
<Display type='currency' value={item.amount} />
</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('status.0')}</span>
<span>{t(`status.${item.status}`)}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('createdAt')}</span>
<time>{formatDate(item.created_at)}</time>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
);
}
@@ -0,0 +1,233 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, preCreateOrder, purchase } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { Badge } from '@shadcn/ui/badge';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent } from '@shadcn/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shadcn/ui/dialog';
import { Input } from '@shadcn/ui/input';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { Separator } from '@shadcn/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { SubscribeBilling } from '../subscribe/billing';
import { SubscribeDetail } from '../subscribe/detail';
export default function Purchase({
subscribe,
setSubscribe,
}: {
subscribe?: API.SubscribeDetails;
setSubscribe: (subscribe?: API.SubscribeDetails) => void;
}) {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const router = useRouter();
const [params, setParams] = useState<API.PurchaseOrderRequest>({
quantity: 1,
subscribe_id: subscribe?.id as number,
payment: 'balance',
coupon: '',
});
const [loading, startTransition] = useTransition();
const { data: order } = useQuery({
queryKey: ['preCreateOrder', params],
queryFn: async () => {
const { data } = await preCreateOrder({
...params,
subscribe_id: subscribe?.id as number,
});
return data.data;
},
enabled: !!params?.subscribe_id,
});
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (subscribe) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscribe?.id,
}));
}
}, [subscribe]);
return (
<Dialog
open={!!subscribe?.id}
onOpenChange={(open) => {
if (!open) setSubscribe(undefined);
}}
>
<DialogContent className='flex h-full max-w-screen-lg flex-col overflow-hidden border-none md:h-auto'>
<DialogHeader>
<DialogTitle>{t('buySubscription')}</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='grid gap-3'>
<div className='font-semibold'>{t('purchaseDuration')}</div>
<RadioGroup
value={String(params.quantity)}
onValueChange={(value) => {
setParams({
...params,
quantity: Number(value),
});
}}
className='flex flex-wrap gap-2'
>
<div className='relative'>
<RadioGroupItem value='1' id='1' className='peer sr-only' />
<Label
htmlFor='1'
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
1 {t('month')}
</Label>
</div>
{subscribe?.discount.map((item) => (
<div key={item.months}>
<RadioGroupItem
value={String(item.months)}
id={String(item.months)}
className='peer sr-only'
/>
<Label
htmlFor={String(item.months)}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
{item.months} {t('months')}
{item.discount < 100 && (
<Badge variant='destructive'>-{100 - item.discount}%</Badge>
)}
</Label>
</div>
))}
</RadioGroup>
<div className='flex'>
<Input
placeholder={t('enterCoupon')}
value={params.coupon}
onChange={(e) => {
setParams({
...params,
coupon: e.target.value.trim(),
});
}}
/>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</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 purchase(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
getUserInfo();
router.push(`/payment?order_no=${orderNo}`);
}
} catch (error) {
console.log(error);
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('buyNow')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,167 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, recharge } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { EnhancedInput } from '@repo/ui/enhanced-input';
import { unitConversion } from '@repo/ui/utils';
import { Button, ButtonProps } from '@shadcn/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@shadcn/ui/dialog';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
export default function Recharge(props: ButtonProps) {
const t = useTranslations('order');
const { common } = useGlobalStore();
const { currency } = common;
const router = useRouter();
const [open, setOpen] = useState<boolean>(false);
const [loading, startTransition] = useTransition();
const [params, setParams] = useState<API.RechargeOrderRequest>({
amount: 0,
payment: '',
});
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (paymentMethods?.length) {
setParams((prev) => ({
...prev,
payment: paymentMethods.find((item) => item.mark !== 'balance')?.mark as string,
}));
}
}, [paymentMethods]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button {...props}>{t('recharge')}</Button>
</DialogTrigger>
<DialogContent className='flex h-full flex-col overflow-hidden md:h-auto'>
<DialogHeader>
<DialogTitle>{t('balanceRecharge')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<div className='flex flex-col justify-between text-sm'>
<div className='grid gap-3'>
<div className='font-semibold'>{t('rechargeAmount')}</div>
<div className='flex'>
<EnhancedInput
type='number'
placeholder={t('enterAmount')}
min={0}
value={params.amount}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
setParams((prev) => ({
...prev,
amount: value as number,
}));
}}
prefix={currency.currency_symbol}
suffix={currency.currency_unit}
/>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods
?.filter((item) => item.mark !== 'balance')
?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</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) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
router.push(`/payment?order_no=${orderNo}`);
setOpen(false);
}
} catch (error) {
/* empty */
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('rechargeNow')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,246 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, preCreateOrder, renewal } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { Badge } from '@shadcn/ui/badge';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent } from '@shadcn/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@shadcn/ui/dialog';
import { Input } from '@shadcn/ui/input';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { Separator } from '@shadcn/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { SubscribeBilling } from '../subscribe/billing';
import { SubscribeDetail } from '../subscribe/detail';
export default function Renewal({
mark,
subscribe,
}: {
mark: string;
subscribe: Omit<API.SubscribeDetails, 'discount'> & {
discount: string | API.UserSubscribeDiscount[];
};
}) {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const [params, setParams] = useState<API.RenewalOrderRequest>({
quantity: 1,
subscribe_id: subscribe.id,
payment: 'balance',
coupon: '',
subscribe_mark: mark,
});
const [loading, startTransition] = useTransition();
const { data: order } = useQuery({
queryKey: ['preCreateOrder', params],
queryFn: async () => {
const { data } = await preCreateOrder({
...params,
subscribe_id: subscribe.id,
});
return data.data;
},
enabled: !!subscribe.id,
});
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (subscribe.id && mark) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscribe.id,
subscribe_mark: mark,
}));
}
}, [subscribe.id, mark]);
function getDiscount() {
try {
if (typeof subscribe.discount === 'string') {
return JSON.parse(subscribe?.discount) as API.UserSubscribeDiscount[];
}
return subscribe?.discount;
} catch (error) {
return [];
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size='sm'>{t('renew')}</Button>
</DialogTrigger>
<DialogContent className='flex h-full max-w-screen-lg flex-col overflow-hidden md:h-auto'>
<DialogHeader>
<DialogTitle>{t('renewSubscription')}</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='grid gap-3'>
<div className='font-semibold'>{t('purchaseDuration')}</div>
<RadioGroup
value={String(params.quantity)}
onValueChange={(value) => {
setParams({
...params,
quantity: Number(value),
});
}}
className='flex flex-wrap gap-2'
>
<div className='relative'>
<RadioGroupItem value='1' id='1' className='peer sr-only' />
<Label
htmlFor='1'
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
1 {t('month')}
</Label>
</div>
{getDiscount().map((item) => (
<div key={item.months}>
<RadioGroupItem
value={String(item.months)}
id={String(item.months)}
className='peer sr-only'
/>
<Label
htmlFor={String(item.months)}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
{item.months} {t('months')}
{item.discount < 100 && (
<Badge variant='destructive'>-{100 - item.discount}%</Badge>
)}
</Label>
</div>
))}
</RadioGroup>
<div className='flex'>
<Input
placeholder={t('enterCoupon')}
value={params.coupon}
onChange={(e) => {
setParams({
...params,
coupon: e.target.value.trim(),
});
}}
/>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</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 renewal(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
getUserInfo();
router.push(`/payment?order_no=${orderNo}`);
}
} catch (error) {
console.log(error);
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('buyNow')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,163 @@
'use client';
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, resetTraffic } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { Button } from '@shadcn/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@shadcn/ui/dialog';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
export default function ResetTraffic({
id,
mark,
replacement,
}: {
id: number;
mark: string;
replacement?: number;
}) {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const [params, setParams] = useState<API.ResetTrafficOrderRequest>({
subscribe_id: id,
payment: 'balance',
subscribe_mark: mark,
});
const [loading, startTransition] = useTransition();
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (id && mark) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: id,
subscribe_mark: mark,
}));
}
}, [id, mark]);
if (!replacement) return;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='outline' size='sm'>
{t('resetTraffic')}
</Button>
</DialogTrigger>
<DialogContent className='flex h-full flex-col overflow-hidden md:h-auto'>
<DialogHeader>
<DialogTitle>{t('resetTrafficTitle')}</DialogTitle>
<DialogDescription>{t('resetTrafficDescription')}</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')}</span>
<span>
<Display type='currency' value={replacement} />
</span>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</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) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
getUserInfo();
router.push(`/payment?order_no=${orderNo}`);
}
} catch (error) {
console.log(error);
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('buyNow')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,255 @@
'use client';
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, queryOrderDetail } from '@/services/user/order';
import { Icon } from '@iconify/react';
import { formatDate } from '@repo/ui/utils';
import { Badge } from '@shadcn/ui/badge';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { addMinutes, format } from '@shadcn/ui/lib/date-fns';
import { Separator } from '@shadcn/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useEffect, useState } from 'react';
import { SubscribeBilling } from '../subscribe/billing';
import { SubscribeDetail } from '../subscribe/detail';
import StripePayment from './stripe';
export default function Page() {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [order_no, setOrderNo] = useState<string>();
const [enabled, setEnabled] = useState<boolean>(false);
const { data } = useQuery({
enabled: enabled,
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,
queryKey: ['checkoutOrder', order_no],
queryFn: async () => {
const { data } = await checkoutOrder({ orderNo: order_no! });
return data?.data;
},
});
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
if (searchParams.get('order_no')) {
setOrderNo(searchParams.get('order_no')!);
setEnabled(true);
}
}, []);
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')}</>
);
return (
<div className='grid gap-4 xl:grid-cols-2'>
<Card className='order-2 xl:order-1'>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<div className='grid gap-0.5'>
<CardTitle className='flex flex-col text-lg'>
{t('orderNumber')}
<span>{data?.orderNo}</span>
</CardTitle>
<CardDescription>
{t('createdAt')}: {formatDate(data?.created_at)}
</CardDescription>
</div>
</CardHeader>
<CardContent className='grid gap-3 p-6 text-sm'>
<div className='font-semibold'>{t('paymentMethod')}</div>
<dl className='grid gap-3'>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>
<Badge>{t(`methods.${data?.method}`)}</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'></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='text-muted-foreground line-clamp-2 flex-1'></span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
{data?.type === 4 && (
<>
<div className='font-semibold'>{t('balanceRecharge')}</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='text-muted-foreground line-clamp-2 flex-1'>
{t('rechargeAmount')}
</span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
<Separator />
<SubscribeBilling
order={{
...data,
coupon: data?.reduction,
quantity: data?.quantity,
unit_price: data?.subscribe?.unit_price,
type: data?.type,
}}
/>
</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='text-2xl font-bold tracking-tight'>{t('paymentSuccess')}</h3>
<Icon icon='mdi:success-circle-outline' className='text-7xl text-green-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/dashboard'>{t('subscribeNow')}</Link>
</Button>
<Button variant='outline'>
<Link href='/document'>{t('viewDocument')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'link' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('waitingForPayment')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<Icon icon='mdi:access-time' className='text-muted-foreground text-7xl' />
<div className='flex gap-4'>
<Button
onClick={() => {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
payment?.checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}}
>
{t('goToPayment')}
</Button>
<Button variant='outline'>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'qr' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<QRCodeCanvas
value={payment?.checkout_url || ''}
size={208}
imageSettings={{
src: `/payment/alipay_f2f.svg`,
width: 48,
height: 48,
excavate: true,
}}
/>
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'stripe' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
{payment.stripe && <StripePayment {...payment.stripe} />}
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/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='text-2xl font-bold tracking-tight'>{t('orderClosed')}</h3>
<Icon icon='mdi:cancel' className='text-7xl text-red-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,109 @@
import { Elements, useStripe } from '@stripe/react-stripe-js';
import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js';
import { QRCodeCanvas } from 'qrcode.react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
interface StripePaymentProps {
method: string;
client_secret: string;
publishable_key: string;
}
const StripePayment: React.FC<StripePaymentProps> = ({
method,
client_secret,
publishable_key,
}) => {
const stripePromise = useMemo(() => loadStripe(publishable_key), [publishable_key]);
return (
<Elements stripe={stripePromise}>
<CheckoutForm method={method} client_secret={client_secret} />
</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 handleError = useCallback((message: string) => {
setErrorMessage(message);
setIsSubmitted(false);
}, []);
const confirmPayment = useCallback(async (): Promise<PaymentIntentResult | null> => {
if (!stripe) {
handleError('Stripe.js is not loaded.');
return null;
}
if (method === 'alipay') {
return await stripe.confirmAlipayPayment(
client_secret,
{ return_url: window.location.href },
{ handleActions: false },
);
}
return await stripe.confirmWechatPayPayment(
client_secret,
{
payment_method_options: { wechat_pay: { client: 'web' } },
},
{ handleActions: false },
);
}, [client_secret, method, stripe, handleError]);
const autoSubmit = useCallback(async () => {
if (isSubmitted) 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('An unexpected error occurred');
}
}, [confirmPayment, isSubmitted, handleError, method]);
useEffect(() => {
autoSubmit();
}, [autoSubmit]);
return qrCodeUrl ? (
<QRCodeCanvas
value={qrCodeUrl}
size={208}
imageSettings={{
src: `/payment/${method}.svg`,
width: 48,
height: 48,
excavate: true,
}}
/>
) : (
errorMessage
);
};
export default StripePayment;
@@ -0,0 +1,87 @@
'use client';
import { updateUserPassword } from '@/services/user/user';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { toast } from '@shadcn/ui/lib/sonner';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useTranslations } from 'next-intl';
export default function ChangePassword() {
const t = useTranslations('profile.accountSettings');
const FormSchema = z
.object({
password: z.string(),
repeat_password: z.string(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('passwordMismatch'),
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,
} as API.UpdateUserPasswordRequest);
toast.success(t('updateSuccess'));
form.setValue('password', '');
form.setValue('repeat_password', '');
}
return (
<Card>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<CardTitle>{t('accountSettings')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 p-6 text-sm'>
<div className='grid gap-3'>
<div className='font-semibold'>{t('loginPassword')}</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input type='password' placeholder={t('newPassword')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='repeat_password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input type='password' placeholder={t('repeatNewPassword')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='size-full' type='submit'>
{t('updatePassword')}
</Button>
</form>
</Form>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,129 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { updateUserNotify } from '@/services/user/user';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { toast } from '@shadcn/ui/lib/sonner';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { Switch } from '@shadcn/ui/switch';
import { useTranslations } from 'next-intl';
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 NotifyEvent() {
const t = useTranslations('profile.notifyEvent');
const { user, getUserInfo } = useGlobalStore();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
enable_balance_notify: user?.enable_balance_notify,
enable_login_notify: user?.enable_login_notify,
enable_subscribe_notify: user?.enable_subscribe_notify,
enable_trade_notify: user?.enable_trade_notify,
},
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
await updateUserNotify(data);
toast.success(t('updateSuccess'));
getUserInfo();
}
return (
<Card>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<CardTitle>{t('notificationEvents')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 p-6 text-sm'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='enable_balance_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('balanceChange')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_login_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('login')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_subscribe_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('subscribe')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_trade_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('finance')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,121 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { updateUserNotifySetting } from '@/services/user/user';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { toast } from '@shadcn/ui/lib/sonner';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { Switch } from '@shadcn/ui/switch';
import { useTranslations } from 'next-intl';
const FormSchema = z.object({
telegram: z.number().nullish(),
enable_email_notify: z.boolean(),
enable_telegram_notify: z.boolean(),
});
export default function NotifySettings() {
const t = useTranslations('profile.notify');
const { user } = useGlobalStore();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
telegram: user?.telegram,
enable_email_notify: user?.enable_email_notify,
enable_telegram_notify: user?.enable_telegram_notify,
},
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
await updateUserNotifySetting(data as API.UpdateUserNotifySettingRequet);
toast.success(t('updateSuccess'));
}
return (
<Card>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<CardTitle>{t('notificationSettings')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 p-6 text-sm'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='telegram'
render={({ field }) => (
<FormItem>
<FormLabel>{t('telegramId')}</FormLabel>
<FormControl>
<div className='flex w-full items-center space-x-2'>
<Input
type='number'
placeholder={t('telegramIdPlaceholder')}
{...field}
value={field.value ? field.value : ''}
onChange={(e) => {
field.onChange(e.target.value ? Number(e.target.value) : '');
}}
/>
<Button
size='sm'
type='button'
onClick={async () => {
form.handleSubmit(onSubmit)();
}}
>
{t('bind')}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_email_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('emailNotification')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_telegram_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('telegramNotification')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,13 @@
import ChangePassword from './change-password';
import NotifyEvent from './notify-event';
import NotifySettings from './notify-settings';
export default function Page() {
return (
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-3'>
<NotifySettings />
<NotifyEvent />
<ChangePassword />
</div>
);
}
@@ -0,0 +1,52 @@
'use client';
import { navs } from '@/config/navs';
import { Icon } from '@iconify/react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@shadcn/ui/sidebar';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>) {
const t = useTranslations('menu');
const pathname = usePathname();
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
tooltip={t(item.title)}
isActive={item.url === pathname}
>
<Link href={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,70 @@
'use client';
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { Icon } from '@iconify/react';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { toast } from '@shadcn/ui/lib/sonner';
import { Sidebar, SidebarContent } from '@shadcn/ui/sidebar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@shadcn/ui/tooltip';
import { useTranslations } from 'next-intl';
import Recharge from './order/recharge';
export function SidebarRight({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { user } = useGlobalStore();
const t = useTranslations('layout');
return (
<Sidebar collapsible='none' side='right' {...props}>
<SidebarContent>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('accountBalance')}</CardTitle>
<Recharge variant='link' className='p-0' />
</CardHeader>
<CardContent className='p-3 text-2xl font-bold'>
<Display type='currency' value={user?.balance} />
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('totalCommission')}</CardTitle>
<Icon icon='mdi:money' className='text-muted-foreground text-2xl' />
</CardHeader>
<CardContent className='p-3 text-2xl font-bold'>0.00</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('invitees')}</CardTitle>
<Icon icon='mdi:users' className='text-muted-foreground text-2xl' />
</CardHeader>
<CardContent className='p-3 text-2xl font-bold'>0</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('inviteCode')}</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
className='size-5 p-0'
onClick={() => {
navigator.clipboard.writeText(
`${location.origin}/auth?invite=${user?.refer_code}`,
);
toast.success(t('copySuccess'));
}}
>
<Icon icon='mdi:content-copy' className='text-primary text-2xl' />
</Button>
</TooltipTrigger>
<TooltipContent>{t('copyInviteLink')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent className='truncate p-3 font-bold'>{user?.refer_code}</CardContent>
</Card>
</SidebarContent>
</Sidebar>
);
}
@@ -0,0 +1,70 @@
'use client';
import { Display } from '@/components/display';
import { Separator } from '@shadcn/ui/separator';
import { useTranslations } from 'next-intl';
interface SubscribeBillingProps {
order?: {
type?: number;
subscribe_id?: number;
quantity?: number;
price?: number;
reduction?: number;
coupon?: number;
fee_amount?: number;
amount?: number;
unit_price?: number;
};
}
export function SubscribeBilling({ order }: SubscribeBillingProps) {
const t = useTranslations('subscribe.billing');
return (
<>
<div className='font-semibold'>{t('billingTitle')}</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('duration')}</span>
<span>
{order?.quantity || 1} {t('months')}
</span>
</li>
)}
<li>
<span className='text-muted-foreground'>{t('price')}</span>
<span>
<Display type='currency' value={order?.price || order?.unit_price} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('productDiscount')}</span>
<span>
<Display type='currency' value={order?.reduction} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('couponDiscount')}</span>
<span>
<Display type='currency' value={order?.coupon} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('fee')}</span>
<span>
<Display type='currency' value={order?.fee_amount} />
</span>
</li>
</ul>
<Separator />
<div className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('total')}</span>
<span>
<Display type='currency' value={order?.amount} />
</span>
</div>
</>
);
}
@@ -0,0 +1,53 @@
'use client';
import { Display } from '@/components/display';
import { useTranslations } from 'next-intl';
interface SubscribeDetailProps {
subscribe?: {
traffic?: number | null;
speed_limit?: number | null;
device_limit?: number | null;
name?: string | null;
quantity?: number | null;
unit_price?: number | null;
};
}
export function SubscribeDetail({ subscribe }: SubscribeDetailProps) {
const t = useTranslations('subscribe.detail');
return (
<>
<div className='font-semibold'>{t('productDetail')}</div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
{subscribe?.name && (
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'>{subscribe?.name}</span>
<span>
x <span>{subscribe?.quantity || 1}</span>
</span>
</li>
)}
<li>
<span className='text-muted-foreground'>{t('availableTraffic')}</span>
<span>
<Display type='traffic' value={subscribe?.traffic} unlimited />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('connectionSpeed')}</span>
<span>
<Display type='traffic' value={subscribe?.speed_limit} unlimited />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('connectedDevices')}</span>
<span>
<Display value={subscribe?.device_limit} type='number' unlimited />
</span>
</li>
</ul>
</>
);
}
@@ -0,0 +1,141 @@
'use client';
import { Display } from '@/components/display';
import { querySubscribeGroupList, querySubscribeList } from '@/services/user/subscribe';
import { Icon } from '@iconify/react';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardFooter, CardHeader } from '@shadcn/ui/card';
import { cn } from '@shadcn/ui/lib/utils';
import { Separator } from '@shadcn/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import Purchase from '../order/purchase';
import { SubscribeDetail } from './detail';
export default function Page() {
const t = useTranslations('subscribe');
const [subscribe, setSubscribe] = useState<API.SubscribeDetails>();
const [group, setGroup] = useState<string>('');
const { data: groups } = useQuery({
queryKey: ['querySubscribeGroupList'],
queryFn: async () => {
const { data } = await querySubscribeGroupList();
return data.data?.list || [];
},
});
const { data } = useQuery({
queryKey: ['querySubscribeList'],
queryFn: async () => {
const { data } = await querySubscribeList();
return data.data?.list || [];
},
});
return (
<>
<Tabs value={group} onValueChange={setGroup} className='space-y-4'>
{groups?.length && (
<>
<h1 className='text-muted-foreground w-full'>{t('category')}</h1>
<TabsList>
<TabsTrigger value=''>{t('all')}</TabsTrigger>
{groups.map((group) => (
<TabsTrigger key={group.id} value={String(group.id)}>
{group.name}
</TabsTrigger>
))}
</TabsList>
<h2 className='text-muted-foreground w-full'>{t('products')}</h2>
</>
)}
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3'>
{data
?.filter((item) => (group ? item.group_id === Number(group) : true))
?.map((item) => (
<Card className='flex flex-col' key={item.id}>
<CardHeader className='bg-muted/50 text-xl font-medium'>{item.name}</CardHeader>
<CardContent className='flex flex-grow flex-col gap-3 p-6 *:!text-sm'>
{/* <div className='font-semibold'>{t('productDescription')}</div> */}
<ul className='flex flex-grow flex-col gap-3'>
{(() => {
let parsedDescription;
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';
support: boolean;
},
index: number,
) => (
<li
className={cn('flex items-center gap-1', {
'text-muted-foreground line-through':
feature.type === 'destructive',
})}
key={index}
>
{feature.icon && (
<Icon
icon={feature.icon}
className={cn('text-primary size-5', {
'text-green-500': feature.type === 'success',
'text-destructive': feature.type === 'destructive',
})}
/>
)}
{feature.label}
</li>
),
)}
</>
);
})()}
</ul>
<SubscribeDetail
subscribe={{
...item,
name: null,
}}
/>
</CardContent>
<Separator />
<CardFooter className='relative mt-2 flex flex-col gap-2'>
<h2 className='pb-5 text-2xl font-semibold sm:text-3xl'>
<Display type='currency' value={item.unit_price} />
<span className='text-base font-medium'>/{t('perMonth')}</span>
</h2>
<Button
className='absolute bottom-0 w-full rounded-b-xl rounded-t-none'
onClick={() => {
setSubscribe(item);
}}
>
{t('buy')}
</Button>
</CardFooter>
</Card>
))}
</div>
</Tabs>
<Purchase subscribe={subscribe} setSubscribe={setSubscribe} />
</>
);
}
+366
View File
@@ -0,0 +1,366 @@
'use client';
import { ProList, ProListActions } from '@/components/pro-list';
import {
createUserTicket,
createUserTicketFollow,
getUserTicketDetails,
getUserTicketList,
updateUserTicketStatus,
} from '@/services/user/ticket';
import { Icon } from '@iconify/react';
import { ConfirmButton } from '@repo/ui/confirm-button';
import { formatDate } from '@repo/ui/utils';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@shadcn/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@shadcn/ui/drawer';
import { Input } from '@shadcn/ui/input';
import { Label } from '@shadcn/ui/label';
import { toast } from '@shadcn/ui/lib/sonner';
import { cn } from '@shadcn/ui/lib/utils';
import { ScrollArea } from '@shadcn/ui/scroll-area';
import { Textarea } from '@shadcn/ui/textarea';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import NextImage from 'next/legacy/image';
import { useEffect, useRef, useState } from 'react';
export default function Page() {
const t = useTranslations('ticket');
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.UserTicket;
},
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>();
const [create, setCreate] = useState<Partial<API.CreateUserTicketRequest & { open: boolean }>>();
return (
<>
<ProList<API.UserTicket, { status: number }>
action={ref}
header={{
title: t('ticketList'),
toolbar: (
<Dialog open={create?.open} onOpenChange={(open) => setCreate({ open })}>
<DialogTrigger asChild>
<Button size='sm'>{t('createTicket')}</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{t('createTicket')}</DialogTitle>
<DialogDescription>{t('createTicketDescription')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<Label htmlFor='title'>{t('title')}</Label>
<Input
id='title'
defaultValue={create?.title}
onChange={(e) => setCreate({ ...create, title: e.target.value! })}
/>
<Label htmlFor='content'>{t('description')}</Label>
<Textarea
id='content'
defaultValue={create?.description}
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'));
setCreate({ open: false });
}}
>
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
),
}}
params={[
{
key: 'search',
},
{
key: 'status',
placeholder: t('status.0'),
options: [
{
label: t('close'),
value: '4',
},
],
},
]}
request={async (pagination, filters) => {
const { data } = await getUserTicketList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 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,
},
)}
>
{t(`status.${item.status}`)}
</span>
</CardTitle>
<CardDescription className='flex gap-2'>
{item.status !== 4 ? (
<>
<Button key='reply' size='sm' onClick={() => setTicketId(item.id)}>
{t('reply')}
</Button>
<ConfirmButton
key='close'
trigger={
<Button variant='destructive' size='sm'>
{t('close')}
</Button>
}
title={t('confirmClose')}
description={t('closeWarning')}
onConfirm={async () => {
await updateUserTicketStatus({ id: item.id, status: 4 });
toast.success(t('closeSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>
</>
) : (
<Button key='check' size='sm' onClick={() => setTicketId(item.id)}>
{t('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')}</span>
<span> {item.title}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('description')}</span>
<time>{item.description}</time>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('updatedAt')}</span>
<time>{formatDate(item.updated_at)}</time>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
<Drawer
open={!!ticketId}
onOpenChange={(open) => {
if (!open) setTicketId(null);
}}
>
<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
key={item.id}
className={cn('flex items-center gap-4', {
'flex-row-reverse': item.from !== 'System',
})}
>
<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('bg-accent w-fit rounded-lg p-2 font-medium', {
'bg-primary text-primary-foreground': item.from !== 'System',
})}
>
{item.type === 1 && item.content}
{item.type === 2 && (
<NextImage
src={item.content!}
width={300}
height={300}
className='!size-auto object-cover'
alt='image'
/>
)}
</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 type='button' variant='outline' className='p-0'>
<Label htmlFor='picture' className='p-2'>
<Icon icon='uil:image-upload' className='text-2xl' />
</Label>
<Input
id='picture'
type='file'
className='hidden'
accept='image/*'
onChange={(event) => {
const file = event.target.files?.[0];
if (file && 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,
);
};
};
}
}}
/>
</Button>
<Input
placeholder={t('inputPlaceholder')}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<Button type='submit' disabled={!message}>
<Icon icon='uil:navigator' />
</Button>
</form>
</DrawerFooter>
)}
</DrawerContent>
</Drawer>
</>
);
}
@@ -0,0 +1,77 @@
'use client';
import { Display } from '@/components/display';
import { ProList, ProListActions } from '@/components/pro-list';
import useGlobalStore from '@/config/use-global';
import { queryUserBalanceLog } from '@/services/user/user';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useTranslations } from 'next-intl';
import { useRef } from 'react';
import { formatDate } from '@repo/ui/utils';
import Recharge from '../order/recharge';
export default function Page() {
const t = useTranslations('wallet');
const { user } = useGlobalStore();
const ref = useRef<ProListActions>(null);
return (
<>
<Card className='mb-4'>
<CardHeader>
<CardTitle className='font-medium'>{t('title')}</CardTitle>
</CardHeader>
<CardContent className='flex items-center justify-between'>
<div className='text-2xl font-bold'>
<Display type='currency' value={user?.balance} />
</div>
<div className='flex gap-2'>
<Recharge />
</div>
</CardContent>
</Card>
<ProList<API.UserBalanceLog, Record<string, unknown>>
action={ref}
request={async (pagination, filter) => {
const response = await queryUserBalanceLog({ ...pagination, ...filter });
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardContent className='p-3 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')}</span>
<time>{formatDate(item.created_at)}</time>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('type.0')}</span>
<span>{t(`type.${item.type}`)}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('amount')}</span>
<span>
<Display type='currency' value={item.amount} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('balance')}</span>
<span>
<Display type='currency' value={item.balance} />
</span>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import Footer from '@/components/footer';
import Header from '@/components/header';
export default async function MainLayout({ children }: { children: React.ReactNode }) {
return (
<>
<Header />
{children}
<Footer />
</>
);
}
+288
View File
@@ -0,0 +1,288 @@
'use client';
import useGlobalStore from '@/config/use-global';
import {
GiftIcon,
GlobalMapIcon,
LocationsIcon,
NetworkSecurityIcon,
ServersIcon,
UsersIcon,
} from '@repo/ui/lotties';
import { Button, buttonVariants } from '@shadcn/ui/button';
import Marquee from '@shadcn/ui/marquee';
import { AnimationProps, motion, MotionProps } from 'framer-motion';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import Link from 'next/link';
import { useMemo } from 'react';
function ScrollAnimationWrapper({
children,
className,
...props
}: AnimationProps &
MotionProps & {
className?: string;
}) {
return (
<motion.section
className={className}
initial='offscreen'
viewport={{ once: true, amount: 0.8 }}
whileInView='onscreen'
{...props}
>
{children}
</motion.section>
);
}
function getScrollAnimation() {
return {
offscreen: {
y: 150,
opacity: 0,
},
onscreen: ({ duration = 2 } = {}) => ({
y: 0,
opacity: 1,
transition: {
type: 'spring',
duration,
},
}),
};
}
const listUser = [
{
name: 'Users',
number: '390',
icon: <UsersIcon className='size-24' />,
},
{
name: 'Locations',
number: '20',
icon: <LocationsIcon className='size-24' />,
},
{
name: 'Server',
number: '50',
icon: <ServersIcon className='size-24' />,
},
];
export default function Page() {
const scrollAnimation = useMemo(() => getScrollAnimation(), []);
const { common, user } = useGlobalStore();
const { site } = common;
const t = useTranslations('index');
return (
<main className='container space-y-16'>
<ScrollAnimationWrapper>
<motion.div
className='grid grid-flow-row grid-rows-2 gap-8 pt-16 sm:grid-flow-col sm:grid-cols-2 md:grid-rows-1'
variants={scrollAnimation}
>
<div className='row-start-2 flex flex-col items-start justify-center sm:row-start-1'>
<h1 className='my-6 text-pretty text-4xl font-bold lg:text-6xl'>
{t('welcome')} {site.site_name}
</h1>
<p className='text-muted-foreground mb-8 max-w-xl lg:text-xl'>{site.site_desc}</p>
<div className='flex w-full flex-col gap-2 sm:flex-row md:justify-start'>
<Link href={user ? '/dashboard' : '/auth'} className={buttonVariants()}>
{t('started')}
</Link>
</div>
</div>
<div className='flex w-full'>
<motion.div className='h-full w-full' variants={scrollAnimation}>
<NetworkSecurityIcon />
</motion.div>
</div>
</motion.div>
</ScrollAnimationWrapper>
<ScrollAnimationWrapper>
<div className='divide-muted z-10 grid w-full grid-flow-row grid-cols-1 divide-y-2 rounded-lg sm:grid-flow-row sm:grid-cols-3 sm:divide-x-2 sm:divide-y-0'>
{listUser.map((item, index) => (
<motion.div
className='mx-auto flex w-8/12 items-center justify-start px-4 py-4 sm:mx-0 sm:w-auto sm:justify-center sm:py-6'
key={index}
custom={{ duration: 2 + index }}
variants={scrollAnimation}
>
<div className='mx-auto flex w-40 items-center sm:w-auto'>
<div className='mr-6 flex h-24 w-24 items-center justify-center rounded-full'>
{item.icon}
</div>
<div className='flex flex-col'>
<p className='text-xl font-bold'>{item.number}+</p>
<p className='text-muted-foreground text-lg'>{item.name}</p>
</div>
</div>
</motion.div>
))}
</div>
</ScrollAnimationWrapper>
<div className='mx-auto flex w-full max-w-screen-xl flex-col justify-center px-6 text-center'>
<div className='flex w-full flex-col'>
<ScrollAnimationWrapper>
<motion.h3
variants={scrollAnimation}
className='text-2xl font-medium leading-relaxed sm:text-3xl lg:text-4xl'
>
{t('choose_plan')}
</motion.h3>
<motion.p
variants={scrollAnimation}
className='mx-auto my-2 w-10/12 text-center leading-normal sm:w-7/12 lg:w-6/12'
>
{t('choose_plan_desc')}
</motion.p>
</ScrollAnimationWrapper>
<div className='grid grid-flow-row grid-cols-1 gap-4 px-6 py-8 sm:grid-flow-col sm:grid-cols-3 sm:px-0 lg:gap-12 lg:px-6 lg:py-12'>
<ScrollAnimationWrapper className='flex justify-center'>
<motion.div
variants={scrollAnimation}
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
whileHover={{
scale: 1.1,
transition: {
duration: 0.2,
},
}}
>
<GiftIcon className='size-48' />
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Free Plan</p>
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
<li className='check custom-list relative my-2'>Encrypted Connection</li>
<li className='check custom-list relative my-2'>No Traffic Logs</li>
<li className='check custom-list relative my-2'>Works on All Devices</li>
</ul>
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
<p className='mb-4 text-center text-2xl'>Free</p>
<Button>Select</Button>
</div>
</motion.div>
</ScrollAnimationWrapper>
<ScrollAnimationWrapper className='flex justify-center'>
<motion.div
variants={scrollAnimation}
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
whileHover={{
scale: 1.1,
transition: {
duration: 0.2,
},
}}
>
<GiftIcon className='size-48' />
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Standard Plan </p>
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
<li className='check custom-list relative my-2'>Encrypted Connection</li>
<li className='check custom-list relative my-2'>No Traffic Logs</li>
<li className='check custom-list relative my-2'>Works on All Devices</li>
<li className='check custom-list relative my-2'>Connect Anyware </li>
</ul>
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
<p className='mb-4 text-center text-2xl'>
$9 <span className='text-muted-foreground'>/ mo</span>
</p>
<Button>Select</Button>
</div>
</motion.div>
</ScrollAnimationWrapper>
<ScrollAnimationWrapper className='flex justify-center'>
<motion.div
variants={scrollAnimation}
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
whileHover={{
scale: 1.1,
transition: {
duration: 0.2,
},
}}
>
<GiftIcon className='size-48' />
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Premium Plan </p>
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
<li className='check custom-list relative my-2'>Encrypted Connection</li>
<li className='check custom-list relative my-2'>No Traffic Logs</li>
<li className='check custom-list relative my-2'>Works on All Devices</li>
<li className='check custom-list relative my-2'>Connect Anyware </li>
<li className='check custom-list relative my-2'>Get New Features </li>
</ul>
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
<p className='mb-4 text-center text-2xl'>
$12 <span className='text-muted-foreground'>/ mo</span>
</p>
<Button>Select</Button>
</div>
</motion.div>
</ScrollAnimationWrapper>
</div>
</div>
</div>
<ScrollAnimationWrapper>
<motion.h3
variants={scrollAnimation}
className='mx-auto text-center text-2xl font-medium leading-relaxed sm:text-3xl lg:text-4xl'
>
{t('huge_network')} {site.site_name}
</motion.h3>
<motion.p className='mx-auto my-2 text-center leading-normal' variants={scrollAnimation}>
{site.site_name} {t('global_network_desc')}
</motion.p>
</ScrollAnimationWrapper>
<ScrollAnimationWrapper>
<motion.div className='aspect-[2/1] w-full overflow-hidden' variants={scrollAnimation}>
<GlobalMapIcon className='-mt-[25%] w-full' />
</motion.div>
</ScrollAnimationWrapper>
<ScrollAnimationWrapper>
<motion.div
className='relative mx-auto flex items-center justify-center overflow-hidden py-6'
variants={scrollAnimation}
>
<Marquee pauseOnHover className='[--duration:20s]'>
{[
'facebook',
'google',
'hbo',
'instagram',
'netflix',
'primevideo',
'reddit',
'snapchat',
'spotify',
'twitch',
'twitter',
'whatsapp',
'youtube',
].map((logo) => (
<div
className='mx-10 flex shrink-0 items-center justify-center dark:invert'
key={logo}
>
<Image
src={`/index/${logo}.png`}
alt={logo}
width={120}
height={48}
className='h-12 w-auto object-contain'
/>
</div>
))}
</Marquee>
<div className='from-background absolute inset-y-0 left-0 w-12 bg-gradient-to-r to-transparent'></div>
<div className='from-background absolute inset-y-0 right-0 w-12 bg-gradient-to-l to-transparent'></div>
</motion.div>
</ScrollAnimationWrapper>
</main>
);
}
+11
View File
@@ -0,0 +1,11 @@
'use client';
import { Markdown } from '@repo/ui/markdown';
export default function Page() {
return (
<div className='container py-8'>
<Markdown></Markdown>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import Link from 'next/link';
import LanguageSwitch from '@/components/language-switch';
import ThemeSwitch from '@/components/theme-switch';
import useGlobalStore from '@/config/use-global';
import { LoginIcon } from '@repo/ui/lotties';
import UserAuthForm from './user-auth-form';
export default function Page() {
const t = useTranslations('auth');
const { common } = useGlobalStore();
const { site } = common;
return (
<main className='bg-muted/50 flex h-full min-h-screen items-center'>
<div className='flex size-full flex-auto flex-col lg:flex-row'>
<div className='flex bg-cover bg-center lg:w-1/2 lg:flex-auto'>
<div className='lg:py-15 md:px-15 flex w-full flex-col items-center justify-center px-5 py-7'>
<Link className='mb-0 flex flex-col items-center lg:mb-12' href='/'>
<Image src={site.site_logo} height={48} width={48} alt='logo' />
<span className='text-2xl font-semibold'>{site.site_name}</span>
</Link>
<LoginIcon className='mx-auto hidden w-[275px] md:w-1/2 lg:block xl:w-[500px]' />
<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='lg:bg-background flex flex-col items-center rounded-2xl p-10 md:w-[600px] lg:flex-auto lg:shadow'>
<div className='flex flex-col items-stretch justify-center md:w-[400px] lg:h-full'>
<div className='pb-15 flex flex-col justify-center lg:flex-auto lg:pb-20'>
<UserAuthForm />
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-5'>
<LanguageSwitch />
<ThemeSwitch />
</div>
<div className='text-primary flex gap-5 text-sm font-semibold'>
<Link href='/tos'>{t('tos')}</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { useLocale } from 'next-intl';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import Turnstile, { useTurnstile } from 'react-turnstile';
import useGlobalStore from '@/config/use-global';
export default function CloudFlareTurnstile({
id,
value,
onChange,
}: {
id?: string;
value?: null | string;
onChange: (value?: string) => void;
}) {
const { common } = useGlobalStore();
const { verify } = common;
const { resolvedTheme } = useTheme();
const locale = useLocale();
const turnstile = useTurnstile();
useEffect(() => {
if (value === '') {
turnstile.reset();
}
}, [turnstile, value]);
return (
verify.turnstile_site_key && (
<Turnstile
id={id}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as 'light' | 'dark'}
language={locale.toLowerCase()}
fixedSize
onVerify={(token) => onChange(token)}
// onError={() => {
// onChange();
// turnstile.reset();
// }}
onExpire={() => {
onChange();
turnstile.reset();
}}
onTimeout={() => {
onChange();
turnstile.reset();
}}
/>
)
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkUser, resetPassword, userLogin, userRegister } from '@/services/common/auth';
import { Icon } from '@iconify/react';
import { Button } from '@shadcn/ui/button';
import { toast } from '@shadcn/ui/lib/sonner';
import { cn } from '@shadcn/ui/lib/utils';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { ReactNode, useState, useTransition } from 'react';
import {
NEXT_PUBLIC_DEFAULT_USER_EMAIL,
NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
} from '@/config/constants';
import { getRedirectUrl, setAuthorization } from '@/utils/common';
import UserCheckForm from './user-check-form';
import UserLoginForm from './user-login-form';
import UserRegisterForm from './user-register-form';
import UserResetForm from './user-reset-form';
export default function UserAuthForm() {
const t = useTranslations('auth');
const { common } = useGlobalStore();
const { register } = common;
const router = useRouter();
const [type, setType] = useState<'login' | 'register' | 'reset'>();
const [loading, startTransition] = useTransition();
const [initialValues, setInitialValues] = useState<{
email?: string;
password?: string;
}>({
email: NEXT_PUBLIC_DEFAULT_USER_EMAIL,
password: NEXT_PUBLIC_DEFAULT_USER_PASSWORD,
});
const handleFormSubmit = async (params: any) => {
const onLogin = async (token?: string) => {
if (!token) return;
setAuthorization(token);
router.replace(getRedirectUrl());
router.refresh();
};
startTransition(async () => {
try {
switch (type) {
case 'login':
// eslint-disable-next-line no-case-declarations
const login = await userLogin(params);
toast.success(t('login.success'));
onLogin(login.data.data?.token);
break;
case 'register':
// eslint-disable-next-line no-case-declarations
const create = await userRegister(params);
toast.success(t('register.success'));
onLogin(create.data.data?.token);
break;
case 'reset':
await resetPassword(params);
toast.success(t('reset.success'));
setType('login');
break;
default:
if (type === 'reset') break;
// eslint-disable-next-line no-case-declarations
const response = await checkUser(params);
setInitialValues({
...initialValues,
...params,
});
setType(response.data.data?.exist ? 'login' : 'register');
break;
}
} catch (error) {
/* empty */
}
});
};
let UserForm: ReactNode = null;
switch (type) {
case 'login':
UserForm = (
<UserLoginForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
setInitialValues={setInitialValues}
onSwitchForm={setType}
/>
);
break;
case 'register':
UserForm = (
<UserRegisterForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
setInitialValues={setInitialValues}
onSwitchForm={setType}
/>
);
break;
case 'reset':
UserForm = (
<UserResetForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
setInitialValues={setInitialValues}
onSwitchForm={setType}
/>
);
break;
default:
UserForm = (
<UserCheckForm
loading={loading}
onSubmit={handleFormSubmit}
initialValues={initialValues}
/>
);
break;
}
return (
<>
<div className='mb-11 text-center'>
<h1 className='mb-3 text-2xl font-bold'>{t(`${type || 'check'}.title`)}</h1>
<div className='text-muted-foreground font-medium'>
{t(`${type || 'check'}.description`)}
</div>
</div>
{!((type === 'register' && register.stop_register) || type === 'reset') && (
<>
<div className='mb-3 flex flex-wrap items-center justify-center gap-3 font-bold'>
<Button type='button' variant='outline'>
<Icon icon='uil:telegram' className='mr-2 size-5' />
Telegram
</Button>
<Button type='button' variant='outline'>
<Icon icon='uil:google' className='mr-2 size-5' />
Google
</Button>
<Button type='button' variant='outline'>
<Icon icon='uil:apple' className='mr-2 size-5' />
Apple
</Button>
</div>
<div
className={cn(
'my-14 flex h-0 items-center text-center',
'before:mr-4 before:block before:w-1/2 before:border-b-[1px]',
'after:ml-4 after:w-1/2 after:border-b-[1px]',
)}
>
<span className='text-muted-foreground w-[125px] text-sm'>{t('orWithEmail')}</span>
</div>
</>
)}
{UserForm}
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
import useGlobalStore from '@/config/use-global';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
export default function UserCheckForm({
loading,
onSubmit,
initialValues,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
}) {
const t = useTranslations('auth.check');
const { common } = useGlobalStore();
const { register } = common;
const formSchema = z.object({
email: z
.string()
.email(t('email'))
.refine(
(email) => {
if (!register.enable_email_domain_suffix) return true;
const domain = email.split('@')[1];
return register.email_domain_suffix_list.split('\n').includes(domain || '');
},
{
message: t('whitelist'),
},
),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleEmailChange = (value: string) => {
form.setValue('email', value);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(async () => {
const isValid = await form.trigger('email');
if (isValid) {
setIsSubmitting(true);
form.handleSubmit(onSubmit)();
} else {
setIsSubmitting(false);
}
}, 500);
};
useEffect(() => {
if (initialValues && initialValues.email) {
handleEmailChange(initialValues.email);
}
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter your email...'
type='email'
{...field}
onChange={(e) => handleEmailChange(e.target.value)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit' disabled={!isSubmitting}>
{isSubmitting ? (
<>
<Icon icon='mdi:loading' className='mr-2 size-5 animate-spin' />
{t('checking')}
</>
) : (
t('continue')
)}
</Button>
</form>
</Form>
);
}
+109
View File
@@ -0,0 +1,109 @@
import useGlobalStore from '@/config/use-global';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction } from 'react';
import CloudFlareTurnstile from './turnstile';
export default function UserLoginForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void;
}) {
const t = useTranslations('auth.login');
const { common } = useGlobalStore();
const { verify } = common;
const formSchema = z.object({
email: z.string(),
password: z.string(),
cf_token: verify.enable_login_verify ? z.string() : z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
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} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button type='submit' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
{t('title')}
</Button>
</form>
</Form>
<div className='mt-4 flex w-full justify-between text-sm'>
<Button variant='link' type='button' className='p-0' onClick={() => onSwitchForm('reset')}>
{t('forgotPassword')}
</Button>
<Button
variant='link'
className='p-0'
onClick={() => {
setInitialValues(undefined);
onSwitchForm(undefined);
}}
>
{t('switchAccount')}
</Button>
</div>
</>
);
}
+204
View File
@@ -0,0 +1,204 @@
import useGlobalStore from '@/config/use-global';
import { sendEmailCode } from '@/services/common/common';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Markdown } from '@repo/ui/markdown';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction, useState } from 'react';
import CloudFlareTurnstile from './turnstile';
export default function UserRegisterForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void;
}) {
const t = useTranslations('auth.register');
const { common } = useGlobalStore();
const { verify, register, invite } = common;
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const handleSendCode = async () => {
await sendEmailCode({
email: initialValues.email,
type: 1,
});
setTargetDate(Date.now() + 60000);
};
const formSchema = z
.object({
email: z.string(),
password: z.string(),
repeat_password: z.string(),
code: register.enable_email_verify ? z.string() : z.string().nullish(),
invite: invite.forced_invite ? z.string() : z.string().nullish(),
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('passwordMismatch'),
path: ['repeat_password'],
});
}
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
...initialValues,
invite: sessionStorage.getItem('invite'),
},
});
return (
<>
{register.stop_register ? (
<Markdown>{t('message')}</Markdown>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
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>
)}
/>
{register.enable_email_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}
/>
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
{seconds > 0 ? `${seconds}s` : t('get')}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='invite'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder={t('invite')}
{...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} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button type='submit' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
{t('title')}
</Button>
</form>
</Form>
)}
<div className='mt-4 text-right text-sm'>
{t('existingAccount')}
<Button
variant='link'
className='p-0'
onClick={() => {
setInitialValues(undefined);
onSwitchForm(undefined);
}}
>
{t('switchToLogin')}
</Button>
</div>
</>
);
}
+153
View File
@@ -0,0 +1,153 @@
import useGlobalStore from '@/config/use-global';
import { sendEmailCode } from '@/services/common/common';
import { Icon } from '@iconify/react/dist/iconify.js';
import { Button } from '@shadcn/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import { Dispatch, SetStateAction, useState } from 'react';
import CloudFlareTurnstile from './turnstile';
export default function UserResetForm({
loading,
onSubmit,
initialValues,
setInitialValues,
onSwitchForm,
}: {
loading?: boolean;
onSubmit: (data: any) => void;
initialValues: any;
setInitialValues: Dispatch<SetStateAction<any>>;
onSwitchForm: (type?: 'register' | 'reset') => void;
}) {
const t = useTranslations('auth.reset');
const { common } = useGlobalStore();
const { verify, register } = common;
const [targetDate, setTargetDate] = useState<number>();
const [, { seconds }] = useCountDown({
targetDate,
onEnd: () => {
setTargetDate(undefined);
},
});
const handleSendCode = async () => {
await sendEmailCode({
email: initialValues.email,
type: 2,
});
setTargetDate(Date.now() + 60000); // 60秒倒计时
};
const formSchema = z.object({
email: z.string(),
password: z.string(),
code: register.enable_email_verify ? z.string() : z.string().nullish(),
cf_token: verify.enable_register_verify ? z.string() : z.string().nullish(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormControl>
<Input disabled placeholder='Enter your email...' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input
disabled={loading}
placeholder='Enter your password...'
type='password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{register.enable_email_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}
/>
<Button type='button' onClick={handleSendCode} disabled={seconds > 0}>
{seconds > 0 ? `${seconds}s` : t('get')}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{verify.enable_reset_password_verify && (
<FormField
control={form.control}
name='cf_token'
render={({ field }) => (
<FormItem>
<FormControl>
<CloudFlareTurnstile id='reset' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button type='submit' disabled={loading}>
{loading && <Icon icon='mdi:loading' className='animate-spin' />}
{t('title')}
</Button>
</form>
</Form>
<div className='mt-4 text-right text-sm'>
{t('existingAccount')}
<Button
variant='link'
className='p-0'
onClick={() => {
setInitialValues(undefined);
onSwitchForm(undefined);
}}
>
{t('switchToLogin')}
</Button>
</div>
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
import Providers from '@/components/providers';
import { geistMono, geistSans } from '@/config/fonts';
import { getGlobalConfig } from '@/services/common/common';
import { queryUserInfo } from '@/services/user/user';
import '@shadcn/ui/globals.css';
import { Toaster } from '@shadcn/ui/sonner';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { PublicEnvScript } from 'next-runtime-env';
import { cookies } from 'next/headers';
import { Metadata } from 'next/types';
import NextTopLoader from 'nextjs-toploader';
import React from 'react';
export async function generateMetadata(): Promise<Metadata> {
const config = await getGlobalConfig({ skipErrorHandler: true }).then((res) => res.data.data!);
const site = config?.site || {};
return {
title: {
default: `${site.site_name}`,
template: `%s | ${site.site_name}`,
},
description: site.site_desc,
icons: {
icon: site.site_logo
? [
{
url: site.site_logo,
sizes: 'any',
},
]
: [],
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages();
let config, user;
try {
config = await getGlobalConfig({ skipErrorHandler: true }).then((res) => res.data.data);
} catch (error) {
/* empty */
}
try {
user = await queryUserInfo({
skipErrorHandler: true,
Authorization: (await cookies()).get('Authorization')?.value,
}).then((res) => res.data.data);
} catch (error) {
/* empty */
}
return (
<html suppressHydrationWarning lang={locale}>
<head>
<PublicEnvScript />
</head>
<body
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} size-full min-h-[calc(100dvh-env(safe-area-inset-top))] antialiased`}
>
<NextIntlClientProvider messages={messages}>
<NextTopLoader showSpinner={false} />
<Providers common={{ ...config }} user={user}>
<Toaster richColors closeButton />
{children}
</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}