🎉 chore(init): project initialization
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user