🎉 chore(init): project initialization

This commit is contained in:
web@ppanel
2024-11-14 01:22:43 +07:00
commit 829edfa824
479 changed files with 61413 additions and 0 deletions
@@ -0,0 +1,40 @@
'use client';
import { queryAnnouncement } from '@/services/user/announcement';
import Empty from '@repo/ui/empty';
import { Markdown } from '@repo/ui/markdown';
import { formatDate } from '@repo/ui/utils';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useQuery } from '@tanstack/react-query';
export default function Page() {
const { data } = useQuery({
queryKey: ['queryAnnouncement'],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: 1,
size: 20,
});
return data.data?.announcements || [];
},
});
return (
<div className='flex flex-col gap-5'>
{data?.length ? (
data.map((item) => (
<Card key={item.id}>
<CardHeader>
<CardTitle>{item.title}</CardTitle>
<CardDescription>{formatDate(item.updated_at)}</CardDescription>
</CardHeader>
<CardContent>
<Markdown>{item.content}</Markdown>
</CardContent>
</Card>
))
) : (
<Empty />
)}
</div>
);
}
@@ -0,0 +1,36 @@
'use client';
import { queryAnnouncement } from '@/services/user/announcement';
import { Icon } from '@iconify/react';
import Empty from '@repo/ui/empty';
import { Markdown } from '@repo/ui/markdown';
import { Card } from '@shadcn/ui/card';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
export default function Announcement() {
const t = useTranslations('dashboard');
const { data } = useQuery({
queryKey: ['queryAnnouncement', 1],
queryFn: async () => {
const { data } = await queryAnnouncement({
page: 1,
size: 1,
});
return (data.data?.announcements?.[0] as API.AnnouncementDetails) || {};
},
});
return (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:bell' className='size-5' />
{t('latestAnnouncement')}
</h2>
<Card className='p-6'>
{data?.content ? <Markdown>{data?.content}</Markdown> : <Empty />}
</Card>
</>
);
}
@@ -0,0 +1,265 @@
'use client';
import { Display } from '@/components/display';
import { queryApplicationConfig } from '@/services/user/subscribe';
import { queryUserSubscribe } from '@/services/user/user';
import { Icon } from '@iconify/react';
import { getNextResetDate, isBrowser } from '@repo/ui/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@shadcn/ui/accordion';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@shadcn/ui/alert-dialog';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { differenceInDays } from '@shadcn/ui/lib/date-fns';
import { toast } from '@shadcn/ui/lib/sonner';
import { Separator } from '@shadcn/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useState } from 'react';
import useGlobalStore from '@/config/use-global';
import Renewal from '../order/renewal';
import ResetTraffic from '../order/reset-traffic';
import Subscribe from '../subscribe/page';
import Announcement from './announcemnet';
export default function Page() {
const t = useTranslations('dashboard');
const { getUserSubscribe, getAppSubLink } = useGlobalStore();
const [protocol, setProtocol] = useState('');
const { data: userSubscribe = [] } = useQuery({
queryKey: ['queryUserSubscribe'],
queryFn: async () => {
const { data } = await queryUserSubscribe();
return data.data?.list || [];
},
});
const { data: application } = useQuery({
queryKey: ['queryApplicationConfig'],
queryFn: async () => {
const { data } = await queryApplicationConfig();
return data.data as API.QueryApplicationConfigResponse;
},
});
const [platform, setPlatform] = useState<keyof API.QueryApplicationConfigResponse>('windows');
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t('copySuccess'));
} catch {
toast.error(t('copyFailure'));
}
};
return (
<div className='flex min-h-[calc(100vh-64px-58px-32px-114px)] w-full flex-col gap-4 overflow-hidden'>
<Announcement />
{userSubscribe.length ? (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:servers' className='size-5' />
{t('mySubscriptions')}
</h2>
<div className='flex flex-wrap justify-between gap-4'>
<Tabs
value={platform}
onValueChange={(value) =>
setPlatform(value as keyof API.QueryApplicationConfigResponse)
}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{application &&
Object.keys(application)?.map((item) => (
<TabsTrigger value={item} key={item} className='px-1 uppercase lg:px-3'>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Tabs
value={protocol}
onValueChange={setProtocol}
className='w-full max-w-full md:w-auto'
>
<TabsList className='flex *:flex-auto'>
{['all', 'ss', 'vmess', 'vless', 'trojan'].map((item) => (
<TabsTrigger
value={item === 'all' ? '' : item}
key={item}
className='px-1 uppercase lg:px-3'
>
{item}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{userSubscribe.map((item) => (
<Card key={item.id}>
<CardHeader className='flex flex-row flex-wrap items-center justify-between gap-2 space-y-0'>
<CardTitle className='font-medium'>{item.subscribe.name}</CardTitle>
<div className='flex gap-2'>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size='sm' variant='destructive'>
{t('resetSubscription')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('prompt')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirmResetSubscription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => toast.success(t('resetSuccess'))}>
{t('confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResetTraffic
id={item.subscribe_id}
mark={item.mark}
replacement={item.subscribe.replacement}
/>
<Renewal mark={item.mark} subscribe={item.subscribe} />
</div>
</CardHeader>
<CardContent>
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col *:justify-between lg:grid-cols-4'>
<li>
<span className='text-muted-foreground'>{t('used')}</span>
<span className='text-2xl font-bold'>
<Display
type='traffic'
value={item.upload + item.download}
unlimited={!item.traffic}
/>
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('totalTraffic')}</span>
<span className='text-2xl font-bold'>
<Display type='traffic' value={item.traffic} unlimited={!item.traffic} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('nextResetDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(getNextResetDate(item.start_time), new Date()) ||
t('unknown')}
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('expirationDays')}</span>
<span className='text-2xl font-semibold'>
{differenceInDays(new Date(item.expire_time), new Date()) || t('unknown')}
</span>
</li>
</ul>
<Separator className='mt-4' />
<Accordion type='single' collapsible defaultValue='0' className='w-full'>
{getUserSubscribe(item.mark, protocol)?.map((url, index) => (
<AccordionItem key={url} value={String(index)}>
<AccordionTrigger className='hover:no-underline'>
<div className='flex w-full flex-row items-center justify-between'>
<CardTitle className='text-sm font-medium'>
{t('subscriptionUrl')} {index + 1}
</CardTitle>
<span
className='text-primary hover:bg-accent mr-4 flex cursor-pointer rounded p-2 text-sm'
onClick={(e) => {
e.stopPropagation();
handleCopy(url);
}}
>
<Icon icon='uil:copy' className='mr-2 size-5' />
{t('copy')}
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className='grid grid-cols-3 gap-4 lg:grid-cols-4 xl:grid-cols-7'>
{application &&
application[platform]?.map((app) => (
<div
key={app.name}
className='text-muted-foreground flex size-full flex-col items-center justify-between gap-2 text-xs'
>
<span>{app.name}</span>
{app.icon && (
<Image src={app.icon} alt={app.name} width={50} height={50} />
)}
<div className='flex'>
<Button size='sm' variant='secondary' className='px-1.5' asChild>
<Link href={app.url!}>{t('download')}</Link>
</Button>
<Button
size='sm'
onClick={() => {
handleCopy(url);
const href = getAppSubLink(app.subscribe_type, url);
if (isBrowser() && href) {
window.location.href = href;
} else {
toast.info(t('manualImportMessage'));
}
}}
className='p-2'
>
{t('import')}
</Button>
</div>
</div>
))}
<div className='text-muted-foreground hidden size-full flex-col items-center justify-between gap-2 text-sm lg:flex'>
<span>{t('qrCode')}</span>
<QRCodeCanvas
value={url}
size={80}
bgColor='transparent'
fgColor='rgb(59, 130, 246)'
/>
<span className='text-center'>{t('scanToSubscribe')}</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
))}
</>
) : (
<>
<h2 className='flex items-center gap-1.5 font-semibold'>
<Icon icon='uil:shop' className='size-5' />
{t('purchaseSubscription')}
</h2>
<Subscribe />
</>
)}
</div>
);
}
@@ -0,0 +1,110 @@
'use client';
import { ProList } from '@/components/pro-list';
import { queryDocumentDetail, queryDocumentList } from '@/services/user/document';
import { Markdown } from '@repo/ui/markdown';
import { formatDate } from '@repo/ui/utils';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Fragment, useState } from 'react';
export default function Page() {
const t = useTranslations('document');
const [tags, setTags] = useState<string[]>([]);
const [selected, setSelected] = useState<number>();
const { data } = useQuery({
enabled: !!selected,
queryKey: ['queryDocumentDetail', selected],
queryFn: async () => {
const { data } = await queryDocumentDetail({
id: selected!,
});
return data.data;
},
});
return (
<Fragment>
{selected ? (
<Card>
<CardHeader className='pb-2'>
<div className='flex items-center justify-between'>
<Button variant='outline' onClick={() => setSelected(undefined)}>
<ChevronLeft className='size-4' />
{t('back')}
</Button>
<CardTitle className='font-medium'>{data?.title}</CardTitle>
<CardDescription>{formatDate(data?.updated_at)}</CardDescription>
</div>
</CardHeader>
<CardContent>
<Markdown>{data?.content || ''}</Markdown>
</CardContent>
</Card>
) : (
<ProList<API.DocumentItem, { tag: string }>
params={[
{
key: 'tag',
placeholder: t('category'),
options: tags.map((item) => ({
label: item,
value: item,
})),
},
]}
request={async (_, filter) => {
const response = await queryDocumentList();
const list = response.data.data?.list || [];
setTags(
Array.from(new Set(list.reduce((acc: string[], item) => acc.concat(item.tags), []))),
);
const filterList = list.filter((item) =>
filter.tag ? item.tags.includes(filter.tag) : true,
);
return {
list: filterList,
total: filterList.length || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
<CardTitle>{item.title}</CardTitle>
<CardDescription>
<Button
size='sm'
onClick={() => {
setSelected(item.id);
}}
>
{t('read')}
</Button>
</CardDescription>
</CardHeader>
<CardContent className='p-3 text-sm'>
<ul className='grid gap-3 *:flex *:flex-col lg:grid-cols-2'>
<li>
<span className='text-muted-foreground'>{t('tags')}</span>
<span>{item.tags.join(', ')}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('updatedAt')}</span>
<time>{formatDate(item.updated_at)}</time>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
)}
</Fragment>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { ScrollArea } from '@shadcn/ui/scroll-area';
import { SidebarInset, SidebarProvider } from '@shadcn/ui/sidebar';
import { SidebarLeft } from './sidebar-left';
import { SidebarRight } from './sidebar-right';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider className='container'>
<SidebarLeft className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
<SidebarInset className='flex-grow overflow-hidden'>
{/* <Header /> */}
<div className='flex h-[calc(100vh-56px)] flex-1 flex-col gap-4 p-4'>
<ScrollArea className='h-full flex-grow overflow-hidden'>{children}</ScrollArea>
</div>
</SidebarInset>
<SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent lg:flex' />
</SidebarProvider>
);
}
@@ -0,0 +1,96 @@
'use client';
import { Display } from '@/components/display';
import { ProList, ProListActions } from '@/components/pro-list';
import { closeOrder, queryOrderList } from '@/services/user/order';
import { formatDate } from '@repo/ui/utils';
import { Button, buttonVariants } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { useRef } from 'react';
export default function Page() {
const t = useTranslations('order');
const ref = useRef<ProListActions>(null);
return (
<ProList<API.OrderDetails, Record<string, unknown>>
action={ref}
request={async (pagination, filter) => {
const response = await queryOrderList({ ...pagination, ...filter });
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
<CardTitle>
{t('orderNo')}
<p className='text-sm'>{item.orderNo}</p>
</CardTitle>
<CardDescription className='flex gap-2'>
{item.status === 1 ? (
<>
<Link
key='payment'
href={`/payment?order_no=${item.orderNo}`}
className={buttonVariants({ size: 'sm' })}
>
{t('payment')}
</Link>
<Button
key='cancel'
size='sm'
variant='destructive'
onClick={async () => {
await closeOrder({ orderNo: item.orderNo });
ref.current?.refresh();
}}
>
{t('cancel')}
</Button>
</>
) : (
<Link
key='detail'
href={`/payment?order_no=${item.orderNo}`}
className={buttonVariants({ size: 'sm' })}
>
{t('detail')}
</Link>
)}
</CardDescription>
</CardHeader>
<CardContent className='p-3 text-sm'>
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-4'>
<li>
<span className='text-muted-foreground'>{t('name')}</span>
<span>{item.subscribe.name || t(`type.${item.type}`)}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('paymentAmount')}</span>
<span>
<Display type='currency' value={item.amount} />
</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('status.0')}</span>
<span>{t(`status.${item.status}`)}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('createdAt')}</span>
<time>{formatDate(item.created_at)}</time>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
);
}
@@ -0,0 +1,233 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, preCreateOrder, purchase } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { Badge } from '@shadcn/ui/badge';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent } from '@shadcn/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shadcn/ui/dialog';
import { Input } from '@shadcn/ui/input';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { Separator } from '@shadcn/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { SubscribeBilling } from '../subscribe/billing';
import { SubscribeDetail } from '../subscribe/detail';
export default function Purchase({
subscribe,
setSubscribe,
}: {
subscribe?: API.SubscribeDetails;
setSubscribe: (subscribe?: API.SubscribeDetails) => void;
}) {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const router = useRouter();
const [params, setParams] = useState<API.PurchaseOrderRequest>({
quantity: 1,
subscribe_id: subscribe?.id as number,
payment: 'balance',
coupon: '',
});
const [loading, startTransition] = useTransition();
const { data: order } = useQuery({
queryKey: ['preCreateOrder', params],
queryFn: async () => {
const { data } = await preCreateOrder({
...params,
subscribe_id: subscribe?.id as number,
});
return data.data;
},
enabled: !!params?.subscribe_id,
});
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (subscribe) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscribe?.id,
}));
}
}, [subscribe]);
return (
<Dialog
open={!!subscribe?.id}
onOpenChange={(open) => {
if (!open) setSubscribe(undefined);
}}
>
<DialogContent className='flex h-full max-w-screen-lg flex-col overflow-hidden border-none md:h-auto'>
<DialogHeader>
<DialogTitle>{t('buySubscription')}</DialogTitle>
</DialogHeader>
<div className='grid w-full gap-3 lg:grid-cols-2'>
<Card className='border-transparent shadow-none md:border-inherit md:shadow'>
<CardContent className='grid gap-3 p-0 text-sm md:p-6'>
<SubscribeDetail
subscribe={{
...subscribe,
quantity: params.quantity,
}}
/>
<Separator />
<SubscribeBilling
order={{
...order,
quantity: params.quantity,
unit_price: subscribe?.unit_price,
}}
/>
</CardContent>
</Card>
<div className='flex flex-col justify-between text-sm'>
<div className='grid gap-3'>
<div className='font-semibold'>{t('purchaseDuration')}</div>
<RadioGroup
value={String(params.quantity)}
onValueChange={(value) => {
setParams({
...params,
quantity: Number(value),
});
}}
className='flex flex-wrap gap-2'
>
<div className='relative'>
<RadioGroupItem value='1' id='1' className='peer sr-only' />
<Label
htmlFor='1'
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
1 {t('month')}
</Label>
</div>
{subscribe?.discount.map((item) => (
<div key={item.months}>
<RadioGroupItem
value={String(item.months)}
id={String(item.months)}
className='peer sr-only'
/>
<Label
htmlFor={String(item.months)}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
{item.months} {t('months')}
{item.discount < 100 && (
<Badge variant='destructive'>-{100 - item.discount}%</Badge>
)}
</Label>
</div>
))}
</RadioGroup>
<div className='flex'>
<Input
placeholder={t('enterCoupon')}
value={params.coupon}
onChange={(e) => {
setParams({
...params,
coupon: e.target.value.trim(),
});
}}
/>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</div>
<Button
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
disabled={loading}
onClick={async () => {
startTransition(async () => {
try {
const response = await purchase(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
getUserInfo();
router.push(`/payment?order_no=${orderNo}`);
}
} catch (error) {
console.log(error);
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('buyNow')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,167 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, recharge } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { EnhancedInput } from '@repo/ui/enhanced-input';
import { unitConversion } from '@repo/ui/utils';
import { Button, ButtonProps } from '@shadcn/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@shadcn/ui/dialog';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
export default function Recharge(props: ButtonProps) {
const t = useTranslations('order');
const { common } = useGlobalStore();
const { currency } = common;
const router = useRouter();
const [open, setOpen] = useState<boolean>(false);
const [loading, startTransition] = useTransition();
const [params, setParams] = useState<API.RechargeOrderRequest>({
amount: 0,
payment: '',
});
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (paymentMethods?.length) {
setParams((prev) => ({
...prev,
payment: paymentMethods.find((item) => item.mark !== 'balance')?.mark as string,
}));
}
}, [paymentMethods]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button {...props}>{t('recharge')}</Button>
</DialogTrigger>
<DialogContent className='flex h-full flex-col overflow-hidden md:h-auto'>
<DialogHeader>
<DialogTitle>{t('balanceRecharge')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<div className='flex flex-col justify-between text-sm'>
<div className='grid gap-3'>
<div className='font-semibold'>{t('rechargeAmount')}</div>
<div className='flex'>
<EnhancedInput
type='number'
placeholder={t('enterAmount')}
min={0}
value={params.amount}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => {
setParams((prev) => ({
...prev,
amount: value as number,
}));
}}
prefix={currency.currency_symbol}
suffix={currency.currency_unit}
/>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods
?.filter((item) => item.mark !== 'balance')
?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</div>
<Button
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
disabled={loading || !params.amount}
onClick={() => {
startTransition(async () => {
try {
const response = await recharge(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
router.push(`/payment?order_no=${orderNo}`);
setOpen(false);
}
} catch (error) {
/* empty */
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('rechargeNow')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,246 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, preCreateOrder, renewal } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { Badge } from '@shadcn/ui/badge';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent } from '@shadcn/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@shadcn/ui/dialog';
import { Input } from '@shadcn/ui/input';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { Separator } from '@shadcn/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { SubscribeBilling } from '../subscribe/billing';
import { SubscribeDetail } from '../subscribe/detail';
export default function Renewal({
mark,
subscribe,
}: {
mark: string;
subscribe: Omit<API.SubscribeDetails, 'discount'> & {
discount: string | API.UserSubscribeDiscount[];
};
}) {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const [params, setParams] = useState<API.RenewalOrderRequest>({
quantity: 1,
subscribe_id: subscribe.id,
payment: 'balance',
coupon: '',
subscribe_mark: mark,
});
const [loading, startTransition] = useTransition();
const { data: order } = useQuery({
queryKey: ['preCreateOrder', params],
queryFn: async () => {
const { data } = await preCreateOrder({
...params,
subscribe_id: subscribe.id,
});
return data.data;
},
enabled: !!subscribe.id,
});
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (subscribe.id && mark) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: subscribe.id,
subscribe_mark: mark,
}));
}
}, [subscribe.id, mark]);
function getDiscount() {
try {
if (typeof subscribe.discount === 'string') {
return JSON.parse(subscribe?.discount) as API.UserSubscribeDiscount[];
}
return subscribe?.discount;
} catch (error) {
return [];
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size='sm'>{t('renew')}</Button>
</DialogTrigger>
<DialogContent className='flex h-full max-w-screen-lg flex-col overflow-hidden md:h-auto'>
<DialogHeader>
<DialogTitle>{t('renewSubscription')}</DialogTitle>
</DialogHeader>
<div className='grid w-full gap-3 lg:grid-cols-2'>
<Card className='border-transparent shadow-none md:border-inherit md:shadow'>
<CardContent className='grid gap-3 p-0 text-sm md:p-6'>
<SubscribeDetail
subscribe={{
...subscribe,
quantity: params.quantity,
}}
/>
<Separator />
<SubscribeBilling
order={{
...order,
quantity: params.quantity,
unit_price: subscribe?.unit_price,
}}
/>
</CardContent>
</Card>
<div className='flex flex-col justify-between text-sm'>
<div className='grid gap-3'>
<div className='font-semibold'>{t('purchaseDuration')}</div>
<RadioGroup
value={String(params.quantity)}
onValueChange={(value) => {
setParams({
...params,
quantity: Number(value),
});
}}
className='flex flex-wrap gap-2'
>
<div className='relative'>
<RadioGroupItem value='1' id='1' className='peer sr-only' />
<Label
htmlFor='1'
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
1 {t('month')}
</Label>
</div>
{getDiscount().map((item) => (
<div key={item.months}>
<RadioGroupItem
value={String(item.months)}
id={String(item.months)}
className='peer sr-only'
/>
<Label
htmlFor={String(item.months)}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary relative flex h-full flex-col items-center justify-center gap-2 rounded-md border-2 p-2'
>
{item.months} {t('months')}
{item.discount < 100 && (
<Badge variant='destructive'>-{100 - item.discount}%</Badge>
)}
</Label>
</div>
))}
</RadioGroup>
<div className='flex'>
<Input
placeholder={t('enterCoupon')}
value={params.coupon}
onChange={(e) => {
setParams({
...params,
coupon: e.target.value.trim(),
});
}}
/>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</div>
<Button
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
disabled={loading}
onClick={async () => {
startTransition(async () => {
try {
const response = await renewal(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
getUserInfo();
router.push(`/payment?order_no=${orderNo}`);
}
} catch (error) {
console.log(error);
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('buyNow')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,163 @@
'use client';
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, resetTraffic } from '@/services/user/order';
import { getAvailablePaymentMethods } from '@/services/user/payment';
import { Button } from '@shadcn/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@shadcn/ui/dialog';
import { Label } from '@shadcn/ui/label';
import { RadioGroup, RadioGroupItem } from '@shadcn/ui/radio-group';
import { useQuery } from '@tanstack/react-query';
import { LoaderCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/legacy/image';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
export default function ResetTraffic({
id,
mark,
replacement,
}: {
id: number;
mark: string;
replacement?: number;
}) {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const [params, setParams] = useState<API.ResetTrafficOrderRequest>({
subscribe_id: id,
payment: 'balance',
subscribe_mark: mark,
});
const [loading, startTransition] = useTransition();
const { data: paymentMethods } = useQuery({
queryKey: ['getAvailablePaymentMethods'],
queryFn: async () => {
const { data } = await getAvailablePaymentMethods();
return data.data?.list || [];
},
});
useEffect(() => {
if (id && mark) {
setParams((prev) => ({
...prev,
quantity: 1,
subscribe_id: id,
subscribe_mark: mark,
}));
}
}, [id, mark]);
if (!replacement) return;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='outline' size='sm'>
{t('resetTraffic')}
</Button>
</DialogTrigger>
<DialogContent className='flex h-full flex-col overflow-hidden md:h-auto'>
<DialogHeader>
<DialogTitle>{t('resetTrafficTitle')}</DialogTitle>
<DialogDescription>{t('resetTrafficDescription')}</DialogDescription>
</DialogHeader>
<div className='flex flex-col justify-between text-sm'>
<div className='grid gap-3'>
<div className='flex justify-between font-semibold'>
<span>{t('resetPrice')}</span>
<span>
<Display type='currency' value={replacement} />
</span>
</div>
<div className='font-semibold'>{t('paymentMethod')}</div>
<RadioGroup
className='grid grid-cols-5 gap-2'
value={params.payment}
onValueChange={(value) => {
setParams({
...params,
payment: value,
});
}}
>
{paymentMethods?.map((item) => {
return (
<div key={item.mark}>
<RadioGroupItem value={item.mark} id={item.mark} className='peer sr-only' />
<Label
htmlFor={item.mark}
className='border-muted bg-popover hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary flex flex-col items-center justify-between rounded-md border-2 py-2'
>
<div className='mb-3 size-12'>
<Image
src={item.icon || `/payment/${item.mark}.svg`}
width={48}
height={48}
alt={item.name!}
/>
</div>
<span className='w-full overflow-hidden text-ellipsis whitespace-nowrap text-center'>
{item.name || t(`methods.${item.mark}`)}
</span>
</Label>
</div>
);
})}
</RadioGroup>
</div>
<Button
className='fixed bottom-0 left-0 w-full rounded-none md:relative md:mt-6'
disabled={loading}
onClick={async () => {
startTransition(async () => {
try {
const response = await resetTraffic(params);
const orderNo = response.data.data?.order_no;
if (orderNo) {
const { data } = await checkoutOrder({
orderNo,
});
const type = data.data?.type;
const checkout_url = data.data?.checkout_url;
if (type === 'link') {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}
getUserInfo();
router.push(`/payment?order_no=${orderNo}`);
}
} catch (error) {
console.log(error);
}
});
}}
>
{loading && <LoaderCircle className='mr-2 animate-spin' />}
{t('buyNow')}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,255 @@
'use client';
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { checkoutOrder, queryOrderDetail } from '@/services/user/order';
import { Icon } from '@iconify/react';
import { formatDate } from '@repo/ui/utils';
import { Badge } from '@shadcn/ui/badge';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import { addMinutes, format } from '@shadcn/ui/lib/date-fns';
import { Separator } from '@shadcn/ui/separator';
import { useQuery } from '@tanstack/react-query';
import { useCountDown } from 'ahooks';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { QRCodeCanvas } from 'qrcode.react';
import { useEffect, useState } from 'react';
import { SubscribeBilling } from '../subscribe/billing';
import { SubscribeDetail } from '../subscribe/detail';
import StripePayment from './stripe';
export default function Page() {
const t = useTranslations('order');
const { getUserInfo } = useGlobalStore();
const [order_no, setOrderNo] = useState<string>();
const [enabled, setEnabled] = useState<boolean>(false);
const { data } = useQuery({
enabled: enabled,
queryKey: ['queryOrderDetail', order_no],
queryFn: async () => {
const { data } = await queryOrderDetail({ order_no: order_no! });
if (data?.data?.status !== 1) {
getUserInfo();
setEnabled(false);
}
return data?.data;
},
refetchInterval: 3000,
});
const { data: payment } = useQuery({
enabled: !!order_no,
queryKey: ['checkoutOrder', order_no],
queryFn: async () => {
const { data } = await checkoutOrder({ orderNo: order_no! });
return data?.data;
},
});
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
if (searchParams.get('order_no')) {
setOrderNo(searchParams.get('order_no')!);
setEnabled(true);
}
}, []);
const [countDown, formattedRes] = useCountDown({
targetDate: data && format(addMinutes(data?.created_at, 15), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"),
});
const { hours, minutes, seconds } = formattedRes;
const countdownDisplay =
countDown > 0 ? (
<>
{hours.toString().length === 1 ? `0${hours}` : hours} :{' '}
{minutes.toString().length === 1 ? `0${minutes}` : minutes} :{' '}
{seconds.toString().length === 1 ? `0${seconds}` : seconds}
</>
) : (
<>{t('timeExpired')}</>
);
return (
<div className='grid gap-4 xl:grid-cols-2'>
<Card className='order-2 xl:order-1'>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<div className='grid gap-0.5'>
<CardTitle className='flex flex-col text-lg'>
{t('orderNumber')}
<span>{data?.orderNo}</span>
</CardTitle>
<CardDescription>
{t('createdAt')}: {formatDate(data?.created_at)}
</CardDescription>
</div>
</CardHeader>
<CardContent className='grid gap-3 p-6 text-sm'>
<div className='font-semibold'>{t('paymentMethod')}</div>
<dl className='grid gap-3'>
<div className='flex items-center justify-between'>
<dt className='text-muted-foreground'>
<Badge>{t(`methods.${data?.method}`)}</Badge>
</dt>
</div>
</dl>
<Separator />
{data?.type && [1, 2].includes(data.type) && (
<SubscribeDetail
subscribe={{
...data?.subscribe,
quantity: data?.quantity,
}}
/>
)}
{data?.type === 3 && (
<>
<div className='font-semibold'></div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'></span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
{data?.type === 4 && (
<>
<div className='font-semibold'>{t('balanceRecharge')}</div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'>
{t('rechargeAmount')}
</span>
<span>
<Display type='currency' value={data.amount} />
</span>
</li>
</ul>
</>
)}
<Separator />
<SubscribeBilling
order={{
...data,
coupon: data?.reduction,
quantity: data?.quantity,
unit_price: data?.subscribe?.unit_price,
type: data?.type,
}}
/>
</CardContent>
</Card>
<Card className='order-1 flex flex-auto items-center justify-center xl:order-2'>
<CardContent className='py-16'>
{data?.status && [2, 5].includes(data?.status) && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('paymentSuccess')}</h3>
<Icon icon='mdi:success-circle-outline' className='text-7xl text-green-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/dashboard'>{t('subscribeNow')}</Link>
</Button>
<Button variant='outline'>
<Link href='/document'>{t('viewDocument')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'link' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('waitingForPayment')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<Icon icon='mdi:access-time' className='text-muted-foreground text-7xl' />
<div className='flex gap-4'>
<Button
onClick={() => {
const width = 600;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
window.open(
payment?.checkout_url,
'newWindow',
`width=${width},height=${height},top=${top},left=${left},menubar=0,scrollbars=1,resizable=1,status=1,titlebar=0,toolbar=0,location=1`,
);
}}
>
{t('goToPayment')}
</Button>
<Button variant='outline'>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'qr' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
<QRCodeCanvas
value={payment?.checkout_url || ''}
size={208}
imageSettings={{
src: `/payment/alipay_f2f.svg`,
width: 48,
height: 48,
excavate: true,
}}
/>
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
{data?.status === 1 && payment?.type === 'stripe' && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('scanToPay')}</h3>
<p className='flex items-center text-3xl font-bold'>{countdownDisplay}</p>
{payment.stripe && <StripePayment {...payment.stripe} />}
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
{data?.status && [3, 4].includes(data?.status) && (
<div className='flex flex-col items-center gap-8 text-center'>
<h3 className='text-2xl font-bold tracking-tight'>{t('orderClosed')}</h3>
<Icon icon='mdi:cancel' className='text-7xl text-red-500' />
<div className='flex gap-4'>
<Button asChild>
<Link href='/subscribe'>{t('productList')}</Link>
</Button>
<Button asChild variant='outline'>
<Link href='/order'>{t('orderList')}</Link>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,109 @@
import { Elements, useStripe } from '@stripe/react-stripe-js';
import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js';
import { QRCodeCanvas } from 'qrcode.react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
interface StripePaymentProps {
method: string;
client_secret: string;
publishable_key: string;
}
const StripePayment: React.FC<StripePaymentProps> = ({
method,
client_secret,
publishable_key,
}) => {
const stripePromise = useMemo(() => loadStripe(publishable_key), [publishable_key]);
return (
<Elements stripe={stripePromise}>
<CheckoutForm method={method} client_secret={client_secret} />
</Elements>
);
};
const CheckoutForm: React.FC<Omit<StripePaymentProps, 'publishable_key'>> = ({
client_secret,
method,
}) => {
const stripe = useStripe();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleError = useCallback((message: string) => {
setErrorMessage(message);
setIsSubmitted(false);
}, []);
const confirmPayment = useCallback(async (): Promise<PaymentIntentResult | null> => {
if (!stripe) {
handleError('Stripe.js is not loaded.');
return null;
}
if (method === 'alipay') {
return await stripe.confirmAlipayPayment(
client_secret,
{ return_url: window.location.href },
{ handleActions: false },
);
}
return await stripe.confirmWechatPayPayment(
client_secret,
{
payment_method_options: { wechat_pay: { client: 'web' } },
},
{ handleActions: false },
);
}, [client_secret, method, stripe, handleError]);
const autoSubmit = useCallback(async () => {
if (isSubmitted) return;
setIsSubmitted(true);
try {
const result = await confirmPayment();
if (!result) return;
const { error, paymentIntent } = result;
if (error) return handleError(error.message!);
if (paymentIntent?.status === 'requires_action') {
const nextAction = paymentIntent.next_action as any;
const qrUrl =
method === 'alipay'
? nextAction?.alipay_handle_redirect?.url
: nextAction?.wechat_pay_display_qr_code?.image_url_svg;
setQrCodeUrl(qrUrl || null);
}
} catch (error) {
handleError('An unexpected error occurred');
}
}, [confirmPayment, isSubmitted, handleError, method]);
useEffect(() => {
autoSubmit();
}, [autoSubmit]);
return qrCodeUrl ? (
<QRCodeCanvas
value={qrCodeUrl}
size={208}
imageSettings={{
src: `/payment/${method}.svg`,
width: 48,
height: 48,
excavate: true,
}}
/>
) : (
errorMessage
);
};
export default StripePayment;
@@ -0,0 +1,87 @@
'use client';
import { updateUserPassword } from '@/services/user/user';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { toast } from '@shadcn/ui/lib/sonner';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { useTranslations } from 'next-intl';
export default function ChangePassword() {
const t = useTranslations('profile.accountSettings');
const FormSchema = z
.object({
password: z.string(),
repeat_password: z.string(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('passwordMismatch'),
path: ['repeat_password'],
});
}
});
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
await updateUserPassword({
password: data.password,
} as API.UpdateUserPasswordRequest);
toast.success(t('updateSuccess'));
form.setValue('password', '');
form.setValue('repeat_password', '');
}
return (
<Card>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<CardTitle>{t('accountSettings')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 p-6 text-sm'>
<div className='grid gap-3'>
<div className='font-semibold'>{t('loginPassword')}</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input type='password' placeholder={t('newPassword')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='repeat_password'
render={({ field }) => (
<FormItem>
<FormControl>
<Input type='password' placeholder={t('repeatNewPassword')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='size-full' type='submit'>
{t('updatePassword')}
</Button>
</form>
</Form>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,129 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { updateUserNotify } from '@/services/user/user';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { toast } from '@shadcn/ui/lib/sonner';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { Switch } from '@shadcn/ui/switch';
import { useTranslations } from 'next-intl';
const FormSchema = z.object({
enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(),
enable_trade_notify: z.boolean(),
});
export default function NotifyEvent() {
const t = useTranslations('profile.notifyEvent');
const { user, getUserInfo } = useGlobalStore();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
enable_balance_notify: user?.enable_balance_notify,
enable_login_notify: user?.enable_login_notify,
enable_subscribe_notify: user?.enable_subscribe_notify,
enable_trade_notify: user?.enable_trade_notify,
},
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
await updateUserNotify(data);
toast.success(t('updateSuccess'));
getUserInfo();
}
return (
<Card>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<CardTitle>{t('notificationEvents')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 p-6 text-sm'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='enable_balance_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('balanceChange')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_login_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('login')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_subscribe_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('subscribe')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_trade_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('finance')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,121 @@
'use client';
import useGlobalStore from '@/config/use-global';
import { updateUserNotifySetting } from '@/services/user/user';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
import { Input } from '@shadcn/ui/input';
import { useForm } from '@shadcn/ui/lib/react-hook-form';
import { toast } from '@shadcn/ui/lib/sonner';
import { z, zodResolver } from '@shadcn/ui/lib/zod';
import { Switch } from '@shadcn/ui/switch';
import { useTranslations } from 'next-intl';
const FormSchema = z.object({
telegram: z.number().nullish(),
enable_email_notify: z.boolean(),
enable_telegram_notify: z.boolean(),
});
export default function NotifySettings() {
const t = useTranslations('profile.notify');
const { user } = useGlobalStore();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
telegram: user?.telegram,
enable_email_notify: user?.enable_email_notify,
enable_telegram_notify: user?.enable_telegram_notify,
},
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
await updateUserNotifySetting(data as API.UpdateUserNotifySettingRequet);
toast.success(t('updateSuccess'));
}
return (
<Card>
<CardHeader className='bg-muted/50 flex flex-row items-start'>
<CardTitle>{t('notificationSettings')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 p-6 text-sm'>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='grid gap-6'>
<FormField
control={form.control}
name='telegram'
render={({ field }) => (
<FormItem>
<FormLabel>{t('telegramId')}</FormLabel>
<FormControl>
<div className='flex w-full items-center space-x-2'>
<Input
type='number'
placeholder={t('telegramIdPlaceholder')}
{...field}
value={field.value ? field.value : ''}
onChange={(e) => {
field.onChange(e.target.value ? Number(e.target.value) : '');
}}
/>
<Button
size='sm'
type='button'
onClick={async () => {
form.handleSubmit(onSubmit)();
}}
>
{t('bind')}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_email_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('emailNotification')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enable_telegram_notify'
render={({ field }) => (
<FormItem className='text-muted-foreground flex items-center justify-between'>
<FormLabel>{t('telegramNotification')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,13 @@
import ChangePassword from './change-password';
import NotifyEvent from './notify-event';
import NotifySettings from './notify-settings';
export default function Page() {
return (
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-3'>
<NotifySettings />
<NotifyEvent />
<ChangePassword />
</div>
);
}
@@ -0,0 +1,52 @@
'use client';
import { navs } from '@/config/navs';
import { Icon } from '@iconify/react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@shadcn/ui/sidebar';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export function SidebarLeft({ ...props }: React.ComponentProps<typeof Sidebar>) {
const t = useTranslations('menu');
const pathname = usePathname();
return (
<Sidebar collapsible='none' side='left' {...props}>
<SidebarContent>
<SidebarMenu>
{navs.map((nav) => (
<SidebarGroup key={nav.title}>
{nav.items && <SidebarGroupLabel>{t(nav.title)}</SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>
{(nav.items || [nav]).map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={t(item.title)}
isActive={item.url === pathname}
>
<Link href={item.url}>
{item.icon && <Icon icon={item.icon} />}
<span>{t(item.title)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarMenu>
</SidebarContent>
</Sidebar>
);
}
@@ -0,0 +1,70 @@
'use client';
import { Display } from '@/components/display';
import useGlobalStore from '@/config/use-global';
import { Icon } from '@iconify/react';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { toast } from '@shadcn/ui/lib/sonner';
import { Sidebar, SidebarContent } from '@shadcn/ui/sidebar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@shadcn/ui/tooltip';
import { useTranslations } from 'next-intl';
import Recharge from './order/recharge';
export function SidebarRight({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { user } = useGlobalStore();
const t = useTranslations('layout');
return (
<Sidebar collapsible='none' side='right' {...props}>
<SidebarContent>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('accountBalance')}</CardTitle>
<Recharge variant='link' className='p-0' />
</CardHeader>
<CardContent className='p-3 text-2xl font-bold'>
<Display type='currency' value={user?.balance} />
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('totalCommission')}</CardTitle>
<Icon icon='mdi:money' className='text-muted-foreground text-2xl' />
</CardHeader>
<CardContent className='p-3 text-2xl font-bold'>0.00</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('invitees')}</CardTitle>
<Icon icon='mdi:users' className='text-muted-foreground text-2xl' />
</CardHeader>
<CardContent className='p-3 text-2xl font-bold'>0</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
<CardTitle className='text-sm font-medium'>{t('inviteCode')}</CardTitle>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
className='size-5 p-0'
onClick={() => {
navigator.clipboard.writeText(
`${location.origin}/auth?invite=${user?.refer_code}`,
);
toast.success(t('copySuccess'));
}}
>
<Icon icon='mdi:content-copy' className='text-primary text-2xl' />
</Button>
</TooltipTrigger>
<TooltipContent>{t('copyInviteLink')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent className='truncate p-3 font-bold'>{user?.refer_code}</CardContent>
</Card>
</SidebarContent>
</Sidebar>
);
}
@@ -0,0 +1,70 @@
'use client';
import { Display } from '@/components/display';
import { Separator } from '@shadcn/ui/separator';
import { useTranslations } from 'next-intl';
interface SubscribeBillingProps {
order?: {
type?: number;
subscribe_id?: number;
quantity?: number;
price?: number;
reduction?: number;
coupon?: number;
fee_amount?: number;
amount?: number;
unit_price?: number;
};
}
export function SubscribeBilling({ order }: SubscribeBillingProps) {
const t = useTranslations('subscribe.billing');
return (
<>
<div className='font-semibold'>{t('billingTitle')}</div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
{order?.type && [1, 2].includes(order?.type) && (
<li>
<span className='text-muted-foreground'>{t('duration')}</span>
<span>
{order?.quantity || 1} {t('months')}
</span>
</li>
)}
<li>
<span className='text-muted-foreground'>{t('price')}</span>
<span>
<Display type='currency' value={order?.price || order?.unit_price} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('productDiscount')}</span>
<span>
<Display type='currency' value={order?.reduction} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('couponDiscount')}</span>
<span>
<Display type='currency' value={order?.coupon} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('fee')}</span>
<span>
<Display type='currency' value={order?.fee_amount} />
</span>
</li>
</ul>
<Separator />
<div className='flex items-center justify-between font-semibold'>
<span className='text-muted-foreground'>{t('total')}</span>
<span>
<Display type='currency' value={order?.amount} />
</span>
</div>
</>
);
}
@@ -0,0 +1,53 @@
'use client';
import { Display } from '@/components/display';
import { useTranslations } from 'next-intl';
interface SubscribeDetailProps {
subscribe?: {
traffic?: number | null;
speed_limit?: number | null;
device_limit?: number | null;
name?: string | null;
quantity?: number | null;
unit_price?: number | null;
};
}
export function SubscribeDetail({ subscribe }: SubscribeDetailProps) {
const t = useTranslations('subscribe.detail');
return (
<>
<div className='font-semibold'>{t('productDetail')}</div>
<ul className='grid grid-cols-2 gap-3 *:flex *:items-center *:justify-between lg:grid-cols-1'>
{subscribe?.name && (
<li className='flex items-center justify-between'>
<span className='text-muted-foreground line-clamp-2 flex-1'>{subscribe?.name}</span>
<span>
x <span>{subscribe?.quantity || 1}</span>
</span>
</li>
)}
<li>
<span className='text-muted-foreground'>{t('availableTraffic')}</span>
<span>
<Display type='traffic' value={subscribe?.traffic} unlimited />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('connectionSpeed')}</span>
<span>
<Display type='traffic' value={subscribe?.speed_limit} unlimited />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('connectedDevices')}</span>
<span>
<Display value={subscribe?.device_limit} type='number' unlimited />
</span>
</li>
</ul>
</>
);
}
@@ -0,0 +1,141 @@
'use client';
import { Display } from '@/components/display';
import { querySubscribeGroupList, querySubscribeList } from '@/services/user/subscribe';
import { Icon } from '@iconify/react';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardFooter, CardHeader } from '@shadcn/ui/card';
import { cn } from '@shadcn/ui/lib/utils';
import { Separator } from '@shadcn/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import Purchase from '../order/purchase';
import { SubscribeDetail } from './detail';
export default function Page() {
const t = useTranslations('subscribe');
const [subscribe, setSubscribe] = useState<API.SubscribeDetails>();
const [group, setGroup] = useState<string>('');
const { data: groups } = useQuery({
queryKey: ['querySubscribeGroupList'],
queryFn: async () => {
const { data } = await querySubscribeGroupList();
return data.data?.list || [];
},
});
const { data } = useQuery({
queryKey: ['querySubscribeList'],
queryFn: async () => {
const { data } = await querySubscribeList();
return data.data?.list || [];
},
});
return (
<>
<Tabs value={group} onValueChange={setGroup} className='space-y-4'>
{groups?.length && (
<>
<h1 className='text-muted-foreground w-full'>{t('category')}</h1>
<TabsList>
<TabsTrigger value=''>{t('all')}</TabsTrigger>
{groups.map((group) => (
<TabsTrigger key={group.id} value={String(group.id)}>
{group.name}
</TabsTrigger>
))}
</TabsList>
<h2 className='text-muted-foreground w-full'>{t('products')}</h2>
</>
)}
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3'>
{data
?.filter((item) => (group ? item.group_id === Number(group) : true))
?.map((item) => (
<Card className='flex flex-col' key={item.id}>
<CardHeader className='bg-muted/50 text-xl font-medium'>{item.name}</CardHeader>
<CardContent className='flex flex-grow flex-col gap-3 p-6 *:!text-sm'>
{/* <div className='font-semibold'>{t('productDescription')}</div> */}
<ul className='flex flex-grow flex-col gap-3'>
{(() => {
let parsedDescription;
try {
parsedDescription = JSON.parse(item.description);
} catch {
parsedDescription = { description: '', features: [] };
}
const { description, features } = parsedDescription;
return (
<>
{description && <li className='text-muted-foreground'>{description}</li>}
{features.map(
(
feature: {
icon: string;
label: string;
type: 'default' | 'success' | 'destructive';
support: boolean;
},
index: number,
) => (
<li
className={cn('flex items-center gap-1', {
'text-muted-foreground line-through':
feature.type === 'destructive',
})}
key={index}
>
{feature.icon && (
<Icon
icon={feature.icon}
className={cn('text-primary size-5', {
'text-green-500': feature.type === 'success',
'text-destructive': feature.type === 'destructive',
})}
/>
)}
{feature.label}
</li>
),
)}
</>
);
})()}
</ul>
<SubscribeDetail
subscribe={{
...item,
name: null,
}}
/>
</CardContent>
<Separator />
<CardFooter className='relative mt-2 flex flex-col gap-2'>
<h2 className='pb-5 text-2xl font-semibold sm:text-3xl'>
<Display type='currency' value={item.unit_price} />
<span className='text-base font-medium'>/{t('perMonth')}</span>
</h2>
<Button
className='absolute bottom-0 w-full rounded-b-xl rounded-t-none'
onClick={() => {
setSubscribe(item);
}}
>
{t('buy')}
</Button>
</CardFooter>
</Card>
))}
</div>
</Tabs>
<Purchase subscribe={subscribe} setSubscribe={setSubscribe} />
</>
);
}
+366
View File
@@ -0,0 +1,366 @@
'use client';
import { ProList, ProListActions } from '@/components/pro-list';
import {
createUserTicket,
createUserTicketFollow,
getUserTicketDetails,
getUserTicketList,
updateUserTicketStatus,
} from '@/services/user/ticket';
import { Icon } from '@iconify/react';
import { ConfirmButton } from '@repo/ui/confirm-button';
import { formatDate } from '@repo/ui/utils';
import { Button } from '@shadcn/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shadcn/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@shadcn/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@shadcn/ui/drawer';
import { Input } from '@shadcn/ui/input';
import { Label } from '@shadcn/ui/label';
import { toast } from '@shadcn/ui/lib/sonner';
import { cn } from '@shadcn/ui/lib/utils';
import { ScrollArea } from '@shadcn/ui/scroll-area';
import { Textarea } from '@shadcn/ui/textarea';
import { useQuery } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import NextImage from 'next/legacy/image';
import { useEffect, useRef, useState } from 'react';
export default function Page() {
const t = useTranslations('ticket');
const [ticketId, setTicketId] = useState<any>(null);
const [message, setMessage] = useState('');
const { data: ticket, refetch: refetchTicket } = useQuery({
queryKey: ['getUserTicketDetails', ticketId],
queryFn: async () => {
const { data } = await getUserTicketDetails({ id: ticketId });
return data.data as API.UserTicket;
},
enabled: !!ticketId,
refetchInterval: 5000,
});
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.children[1]?.scrollTo({
top: scrollRef.current.children[1].scrollHeight,
behavior: 'smooth',
});
}
}, 66);
}, [ticket?.follow?.length]);
const ref = useRef<ProListActions>();
const [create, setCreate] = useState<Partial<API.CreateUserTicketRequest & { open: boolean }>>();
return (
<>
<ProList<API.UserTicket, { status: number }>
action={ref}
header={{
title: t('ticketList'),
toolbar: (
<Dialog open={create?.open} onOpenChange={(open) => setCreate({ open })}>
<DialogTrigger asChild>
<Button size='sm'>{t('createTicket')}</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{t('createTicket')}</DialogTitle>
<DialogDescription>{t('createTicketDescription')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<Label htmlFor='title'>{t('title')}</Label>
<Input
id='title'
defaultValue={create?.title}
onChange={(e) => setCreate({ ...create, title: e.target.value! })}
/>
<Label htmlFor='content'>{t('description')}</Label>
<Textarea
id='content'
defaultValue={create?.description}
onChange={(e) => setCreate({ ...create, description: e.target.value! })}
/>
</div>
<DialogFooter>
<Button
disabled={!create?.title || !create?.description}
onClick={async () => {
await createUserTicket({
title: create!.title!,
description: create!.description!,
});
ref.current?.refresh();
toast.success(t('createSuccess'));
setCreate({ open: false });
}}
>
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
),
}}
params={[
{
key: 'search',
},
{
key: 'status',
placeholder: t('status.0'),
options: [
{
label: t('close'),
value: '4',
},
],
},
]}
request={async (pagination, filters) => {
const { data } = await getUserTicketList({
...pagination,
...filters,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardHeader className='bg-muted/50 flex flex-row items-center justify-between gap-2 space-y-0 p-3'>
<CardTitle>
<span
className={cn(
'flex items-center gap-2 before:block before:size-1.5 before:animate-pulse before:rounded-full before:ring-2 before:ring-opacity-50',
{
'before:bg-yellow-500 before:ring-yellow-500': item.status === 1,
'before:bg-rose-500 before:ring-rose-500': item.status === 2,
'before:bg-green-500 before:ring-green-500': item.status === 3,
'before:bg-zinc-500 before:ring-zinc-500': item.status === 4,
},
)}
>
{t(`status.${item.status}`)}
</span>
</CardTitle>
<CardDescription className='flex gap-2'>
{item.status !== 4 ? (
<>
<Button key='reply' size='sm' onClick={() => setTicketId(item.id)}>
{t('reply')}
</Button>
<ConfirmButton
key='close'
trigger={
<Button variant='destructive' size='sm'>
{t('close')}
</Button>
}
title={t('confirmClose')}
description={t('closeWarning')}
onConfirm={async () => {
await updateUserTicketStatus({ id: item.id, status: 4 });
toast.success(t('closeSuccess'));
ref.current?.refresh();
}}
cancelText={t('cancel')}
confirmText={t('confirm')}
/>
</>
) : (
<Button key='check' size='sm' onClick={() => setTicketId(item.id)}>
{t('check')}
</Button>
)}
</CardDescription>
</CardHeader>
<CardContent className='p-3 text-sm'>
<ul className='grid gap-3 *:flex *:flex-col lg:grid-cols-3'>
<li>
<span className='text-muted-foreground'>{t('title')}</span>
<span> {item.title}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('description')}</span>
<time>{item.description}</time>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('updatedAt')}</span>
<time>{formatDate(item.updated_at)}</time>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
<Drawer
open={!!ticketId}
onOpenChange={(open) => {
if (!open) setTicketId(null);
}}
>
<DrawerContent className='container mx-auto h-screen'>
<DrawerHeader className='border-b text-left'>
<DrawerTitle>{ticket?.title}</DrawerTitle>
<DrawerDescription className='line-clamp-3'>{ticket?.description}</DrawerDescription>
</DrawerHeader>
<ScrollArea className='h-full overflow-hidden' ref={scrollRef}>
<div className='flex flex-col gap-4 p-4'>
{ticket?.follow?.map((item) => (
<div
key={item.id}
className={cn('flex items-center gap-4', {
'flex-row-reverse': item.from !== 'System',
})}
>
<div
className={cn('flex flex-col gap-1', {
'items-end': item.from !== 'System',
})}
>
<p className='text-muted-foreground text-sm'>{formatDate(item.created_at)}</p>
<p
className={cn('bg-accent w-fit rounded-lg p-2 font-medium', {
'bg-primary text-primary-foreground': item.from !== 'System',
})}
>
{item.type === 1 && item.content}
{item.type === 2 && (
<NextImage
src={item.content!}
width={300}
height={300}
className='!size-auto object-cover'
alt='image'
/>
)}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
{ticket?.status !== 4 && (
<DrawerFooter>
<form
className='flex w-full flex-row items-center gap-2'
onSubmit={async (event) => {
event.preventDefault();
if (message) {
await createUserTicketFollow({
ticket_id: ticketId,
from: 'User',
type: 1,
content: message,
});
refetchTicket();
setMessage('');
}
}}
>
<Button type='button' variant='outline' className='p-0'>
<Label htmlFor='picture' className='p-2'>
<Icon icon='uil:image-upload' className='text-2xl' />
</Label>
<Input
id='picture'
type='file'
className='hidden'
accept='image/*'
onChange={(event) => {
const file = event.target.files?.[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
const img = new Image();
img.src = e.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const maxWidth = 300;
const maxHeight = 300;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height = Math.round((maxWidth / width) * height);
width = maxWidth;
}
} else {
if (height > maxHeight) {
width = Math.round((maxHeight / height) * width);
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
const reader = new FileReader();
reader.readAsDataURL(blob!);
reader.onloadend = async () => {
await createUserTicketFollow({
ticket_id: ticketId,
from: 'User',
type: 2,
content: reader.result as string,
});
refetchTicket();
};
},
'image/webp',
0.8,
);
};
};
}
}}
/>
</Button>
<Input
placeholder={t('inputPlaceholder')}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<Button type='submit' disabled={!message}>
<Icon icon='uil:navigator' />
</Button>
</form>
</DrawerFooter>
)}
</DrawerContent>
</Drawer>
</>
);
}
@@ -0,0 +1,77 @@
'use client';
import { Display } from '@/components/display';
import { ProList, ProListActions } from '@/components/pro-list';
import useGlobalStore from '@/config/use-global';
import { queryUserBalanceLog } from '@/services/user/user';
import { Card, CardContent, CardHeader, CardTitle } from '@shadcn/ui/card';
import { useTranslations } from 'next-intl';
import { useRef } from 'react';
import { formatDate } from '@repo/ui/utils';
import Recharge from '../order/recharge';
export default function Page() {
const t = useTranslations('wallet');
const { user } = useGlobalStore();
const ref = useRef<ProListActions>(null);
return (
<>
<Card className='mb-4'>
<CardHeader>
<CardTitle className='font-medium'>{t('title')}</CardTitle>
</CardHeader>
<CardContent className='flex items-center justify-between'>
<div className='text-2xl font-bold'>
<Display type='currency' value={user?.balance} />
</div>
<div className='flex gap-2'>
<Recharge />
</div>
</CardContent>
</Card>
<ProList<API.UserBalanceLog, Record<string, unknown>>
action={ref}
request={async (pagination, filter) => {
const response = await queryUserBalanceLog({ ...pagination, ...filter });
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
renderItem={(item) => {
return (
<Card className='overflow-hidden'>
<CardContent className='p-3 text-sm'>
<ul className='grid grid-cols-2 gap-3 *:flex *:flex-col lg:grid-cols-4'>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('createdAt')}</span>
<time>{formatDate(item.created_at)}</time>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('type.0')}</span>
<span>{t(`type.${item.type}`)}</span>
</li>
<li className='font-semibold'>
<span className='text-muted-foreground'>{t('amount')}</span>
<span>
<Display type='currency' value={item.amount} />
</span>
</li>
<li>
<span className='text-muted-foreground'>{t('balance')}</span>
<span>
<Display type='currency' value={item.balance} />
</span>
</li>
</ul>
</CardContent>
</Card>
);
}}
/>
</>
);
}