fix: demo首页
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import Affiliate from '@/components/affiliate';
|
||||
|
||||
export default function Page() {
|
||||
return <Affiliate />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Empty } from '@/components/empty';
|
||||
import { queryAnnouncement } from '@/services/user/announcement';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Timeline } from '@workspace/ui/components/timeline';
|
||||
import { Markdown } from '@workspace/ui/custom-components/markdown';
|
||||
|
||||
export default function Page() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['queryAnnouncement'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryAnnouncement({
|
||||
page: 1,
|
||||
size: 99,
|
||||
pinned: false,
|
||||
popup: false,
|
||||
});
|
||||
return data.data?.announcements || [];
|
||||
},
|
||||
});
|
||||
return data && data.length > 0 ? (
|
||||
<Timeline
|
||||
data={
|
||||
data.map((item) => ({
|
||||
title: item.title,
|
||||
content: <Markdown>{item.content}</Markdown>,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Empty />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import Renewal from '@/components/subscribe/renewal';
|
||||
import ResetTraffic from '@/components/subscribe/reset-traffic';
|
||||
import Unsubscribe from '@/components/subscribe/unsubscribe';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { getStat } from '@/services/common/common';
|
||||
import { queryApplicationConfig } from '@/services/user/subscribe';
|
||||
import { queryUserSubscribe, resetUserSubscribeToken } from '@/services/user/user';
|
||||
import { getPlatform } from '@/utils/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@workspace/ui/components/accordion';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@workspace/ui/components/alert-dialog';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
|
||||
import { Separator } from '@workspace/ui/components/separator';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { differenceInDays, formatDate, isBrowser } from '@workspace/ui/utils';
|
||||
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 CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { toast } from 'sonner';
|
||||
import Subscribe from '../subscribe/page';
|
||||
|
||||
const platforms: (keyof API.ApplicationPlatform)[] = [
|
||||
'windows',
|
||||
'macos',
|
||||
'linux',
|
||||
'ios',
|
||||
'android',
|
||||
'harmony',
|
||||
];
|
||||
|
||||
export default function Content() {
|
||||
const t = useTranslations('dashboard');
|
||||
const { getUserSubscribe, getAppSubLink } = useGlobalStore();
|
||||
|
||||
const [protocol, setProtocol] = useState('');
|
||||
|
||||
const {
|
||||
data: userSubscribe = [],
|
||||
refetch,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['queryUserSubscribe'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryUserSubscribe();
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
const { data: applications } = useQuery({
|
||||
queryKey: ['queryApplicationConfig'],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryApplicationConfig();
|
||||
return data.data?.applications || [];
|
||||
},
|
||||
});
|
||||
const [platform, setPlatform] = useState<keyof API.ApplicationPlatform>(getPlatform());
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['getStat'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getStat({
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const statusWatermarks = {
|
||||
2: t('finished'),
|
||||
3: t('expired'),
|
||||
4: t('deducted'),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{userSubscribe.length ? (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='flex items-center gap-1.5 font-semibold'>
|
||||
<Icon icon='uil:servers' className='size-5' />
|
||||
{t('mySubscriptions')}
|
||||
</h2>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
refetch();
|
||||
}}
|
||||
className={isLoading ? 'animate-pulse' : ''}
|
||||
>
|
||||
<Icon icon='uil:sync' />
|
||||
</Button>
|
||||
<Button size='sm' asChild>
|
||||
<Link href='/subscribe'>{t('purchaseSubscription')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap justify-between gap-4'>
|
||||
{/*<Tabs
|
||||
value={platform}
|
||||
onValueChange={(value) => setPlatform(value as keyof API.ApplicationPlatform)}
|
||||
className='w-full max-w-full md:w-auto'
|
||||
>
|
||||
<TabsList className='flex *:flex-auto'>
|
||||
{platforms.map((item) => (
|
||||
<TabsTrigger value={item} key={item} className='px-1 lg:px-3'>
|
||||
<Icon
|
||||
icon={`${
|
||||
{
|
||||
windows: 'mdi:microsoft-windows',
|
||||
macos: 'uil:apple',
|
||||
linux: 'uil:linux',
|
||||
ios: 'simple-icons:ios',
|
||||
android: 'uil:android',
|
||||
harmony: 'simple-icons:harmonyos',
|
||||
}[item]
|
||||
}`}
|
||||
className='size-5'
|
||||
/>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>*/}
|
||||
{data?.protocol && data?.protocol.length > 1 && (
|
||||
<Tabs
|
||||
value={protocol}
|
||||
onValueChange={setProtocol}
|
||||
className='w-full max-w-full md:w-auto'
|
||||
>
|
||||
<TabsList className='flex *:flex-auto'>
|
||||
{['all', ...(data?.protocol || [])].map((item) => (
|
||||
<TabsTrigger
|
||||
value={item === 'all' ? '' : item}
|
||||
key={item}
|
||||
className='px-1 uppercase lg:px-3'
|
||||
>
|
||||
{item}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
{userSubscribe.map((item) => {
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={cn('relative', {
|
||||
'relative opacity-80 grayscale': item.status === 3,
|
||||
'relative hidden opacity-60 blur-[0.3px] grayscale': item.status === 4,
|
||||
})}
|
||||
>
|
||||
{item.status >= 2 && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0 z-10 h-full w-full overflow-hidden mix-blend-difference',
|
||||
{
|
||||
'text-destructive': item.status === 2,
|
||||
'text-white': item.status === 3 || item.status === 4,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
filter: 'contrast(200%) brightness(150%) invert(0.2)',
|
||||
}}
|
||||
>
|
||||
<div className='absolute inset-0'>
|
||||
{Array.from({ length: 16 }).map((_, i) => {
|
||||
const row = Math.floor(i / 4);
|
||||
const col = i % 4;
|
||||
// 计算位置百分比
|
||||
const top = 10 + row * 25 + (col % 2 === 0 ? 5 : -5);
|
||||
const left = 5 + col * 30 + (row % 2 === 0 ? 0 : 10);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className='absolute rotate-[-30deg] whitespace-nowrap text-lg font-black opacity-40'
|
||||
style={{
|
||||
top: `${top}%`,
|
||||
left: `${left}%`,
|
||||
textShadow: '0px 0px 1px rgba(255,255,255,0.5)',
|
||||
}}
|
||||
>
|
||||
{statusWatermarks[item.status as keyof typeof statusWatermarks]}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className='flex flex-row flex-wrap items-center justify-between gap-2 space-y-0'>
|
||||
<CardTitle className='font-medium'>
|
||||
{item.subscribe.name}
|
||||
<p className='text-foreground/50 mt-1 text-sm'>{formatDate(item.start_time)}</p>
|
||||
</CardTitle>
|
||||
{item.status !== 4 && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size='sm' variant='destructive'>
|
||||
{t('resetSubscription')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('prompt')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('confirmResetSubscription')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await resetUserSubscribeToken({
|
||||
user_subscribe_id: item.id,
|
||||
});
|
||||
await refetch();
|
||||
toast.success(t('resetSuccess'));
|
||||
}}
|
||||
>
|
||||
{t('confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ResetTraffic id={item.id} replacement={item.subscribe.replacement} />
|
||||
<Renewal id={item.id} subscribe={item.subscribe} />
|
||||
|
||||
<Unsubscribe id={item.id} allowDeduction={item.subscribe.allow_deduction} />
|
||||
</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'>
|
||||
{item.reset_time
|
||||
? differenceInDays(new Date(item.reset_time), new Date())
|
||||
: t('noReset')}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className='text-muted-foreground'>{t('expirationDays')}</span>
|
||||
<span className='text-2xl font-semibold'>
|
||||
{}
|
||||
{item.expire_time
|
||||
? differenceInDays(new Date(item.expire_time), new Date()) || t('unknown')
|
||||
: t('noLimit')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Separator className='mt-4' />
|
||||
<Accordion type='single' collapsible defaultValue='0' className='w-full'>
|
||||
{getUserSubscribe(item.token, protocol)?.map((url, index) => (
|
||||
<AccordionItem key={url} value={String(index)}>
|
||||
<AccordionTrigger className='hover:no-underline'>
|
||||
<div className='flex w-full flex-row items-center justify-between'>
|
||||
<CardTitle className='text-sm font-medium'>
|
||||
{t('subscriptionUrl')} {index + 1}
|
||||
</CardTitle>
|
||||
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
onCopy={(text, result) => {
|
||||
if (result) {
|
||||
toast.success(t('copySuccess'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className='text-primary hover:bg-accent mr-4 flex cursor-pointer rounded p-2 text-sm'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon icon='uil:copy' className='mr-2 size-5' />
|
||||
{t('copy')}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'>
|
||||
{applications
|
||||
?.filter((application) => {
|
||||
const platformApps = application.platform?.[platform];
|
||||
return platformApps && platformApps.length > 0;
|
||||
})
|
||||
.map((application) => {
|
||||
const platformApps = application.platform?.[platform];
|
||||
const app =
|
||||
platformApps?.find((item) => item.is_default) ||
|
||||
platformApps?.[0];
|
||||
if (!app) return null;
|
||||
|
||||
const handleCopy = (text: string, result: boolean) => {
|
||||
if (result) {
|
||||
const href = getAppSubLink(application.subscribe_type, url);
|
||||
const showSuccessMessage = () => {
|
||||
toast.success(
|
||||
<>
|
||||
<p>{t('copySuccess')}</p>
|
||||
<br />
|
||||
<p>{t('manualImportMessage')}</p>
|
||||
</>,
|
||||
);
|
||||
};
|
||||
|
||||
if (isBrowser() && href) {
|
||||
window.location.href = href;
|
||||
const checkRedirect = setTimeout(() => {
|
||||
if (window.location.href !== href) {
|
||||
showSuccessMessage();
|
||||
}
|
||||
clearTimeout(checkRedirect);
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={application.name}
|
||||
className='text-muted-foreground flex size-full flex-col items-center justify-between gap-2 text-xs'
|
||||
>
|
||||
<span>{application.name}</span>
|
||||
|
||||
{application.icon && (
|
||||
<Image
|
||||
src={application.icon}
|
||||
alt={application.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className='p-1'
|
||||
/>
|
||||
)}
|
||||
<div className='flex'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
className='rounded-r-none px-1.5'
|
||||
asChild
|
||||
>
|
||||
<Link href={app.url}>{t('download')}</Link>
|
||||
</Button>
|
||||
|
||||
<CopyToClipboard
|
||||
text={getAppSubLink(application.subscribe_type, url) || url}
|
||||
onCopy={handleCopy}
|
||||
>
|
||||
<Button size='sm' className='rounded-l-none p-2'>
|
||||
{t('import')}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Announcement from '@/components/announcement';
|
||||
import { cookies } from 'next/headers';
|
||||
import Content from './content';
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<div className='flex min-h-[calc(100vh-64px-58px-32px-114px)] w-full flex-col gap-4 overflow-hidden'>
|
||||
<Announcement type='pinned' Authorization={(await cookies()).get('Authorization')?.value} />
|
||||
<Content />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export const CloseIcon = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<motion.svg
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.05,
|
||||
},
|
||||
}}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={cn('h-4 w-4', className)}
|
||||
>
|
||||
<path stroke='none' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M18 6l-12 12' />
|
||||
<path d='M6 6l12 12' />
|
||||
</motion.svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { queryDocumentDetail } from '@/services/user/document';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarFallback } from '@workspace/ui/components/avatar';
|
||||
import { buttonVariants } from '@workspace/ui/components/button';
|
||||
import { Markdown } from '@workspace/ui/custom-components/markdown';
|
||||
import { useOutsideClick } from '@workspace/ui/hooks/use-outside-click';
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { RefObject, useEffect, useId, useRef, useState } from 'react';
|
||||
import { CloseIcon } from './close-icon';
|
||||
|
||||
export function DocumentButton({ items }: { items: API.Document[] }) {
|
||||
const t = useTranslations('document');
|
||||
const [active, setActive] = useState<API.Document | boolean | null>(null);
|
||||
const id = useId();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: !!(active as API.Document)?.id,
|
||||
queryKey: ['queryDocumentDetail', (active as API.Document)?.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryDocumentDetail({
|
||||
id: (active as API.Document)?.id,
|
||||
});
|
||||
return data.data?.content;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (active && typeof active === 'object') {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [active]);
|
||||
|
||||
useOutsideClick(ref as RefObject<HTMLDivElement>, () => setActive(null));
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{active && typeof active === 'object' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className='fixed inset-0 z-10 h-full w-full bg-black/20'
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{active && typeof active === 'object' ? (
|
||||
<div className='fixed inset-0 z-[100] grid place-items-center'>
|
||||
<motion.button
|
||||
key={`button-${active.title}-${id}`}
|
||||
layout
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.05,
|
||||
},
|
||||
}}
|
||||
className='bg-foreground absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full text-white dark:text-black'
|
||||
onClick={() => setActive(null)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</motion.button>
|
||||
<motion.div
|
||||
layoutId={`card-${active.id}-${id}`}
|
||||
ref={ref}
|
||||
className='bg-muted flex size-full flex-col overflow-auto p-6 sm:rounded'
|
||||
>
|
||||
<Markdown>{data || ''}</Markdown>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<ul className='flex w-full flex-col gap-4'>
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
layoutId={`card-${item.id}-${id}`}
|
||||
key={`card-${item.id}-${id}`}
|
||||
onClick={() => setActive(item)}
|
||||
className='bg-background hover:bg-accent flex cursor-pointer items-center justify-between rounded border p-4'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-4'>
|
||||
<motion.div layoutId={`image-${item.id}-${id}`}>
|
||||
<Avatar className='size-12'>
|
||||
<AvatarFallback className='bg-primary/80 text-white'>
|
||||
{item.title.split('')[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
<div className=''>
|
||||
<motion.h3 layoutId={`title-${item.id}-${id}`} className='font-medium'>
|
||||
{item.title}
|
||||
</motion.h3>
|
||||
<motion.p
|
||||
layoutId={`description-${item.id}-${id}`}
|
||||
className='text-sm text-neutral-600 dark:text-neutral-400'
|
||||
>
|
||||
{formatDate(item.updated_at)}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
layoutId={`button-${item.id}-${id}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
}),
|
||||
'rounded-full',
|
||||
)}
|
||||
>
|
||||
{t('read')}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { queryDocumentList } from '@/services/user/document';
|
||||
import { getTutorialList } from '@/utils/tutorial';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { DocumentButton } from './document-button';
|
||||
import { TutorialButton } from './tutorial-button';
|
||||
|
||||
export default function Page() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('document');
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['queryDocumentList'],
|
||||
queryFn: async () => {
|
||||
const response = await queryDocumentList();
|
||||
const list = response.data.data?.list || [];
|
||||
return {
|
||||
tags: Array.from(new Set(list.reduce((acc: string[], item) => acc.concat(item.tags), []))),
|
||||
list,
|
||||
};
|
||||
},
|
||||
});
|
||||
const { tags, list: DocumentList } = data || { tags: [], list: [] };
|
||||
|
||||
const { data: TutorialList } = useQuery({
|
||||
queryKey: ['getTutorialList', locale],
|
||||
queryFn: async () => {
|
||||
const list = await getTutorialList();
|
||||
return list.get(locale);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{DocumentList?.length > 0 && (
|
||||
<>
|
||||
<h2 className='flex items-center gap-1.5 font-semibold'>{t('document')}</h2>
|
||||
<Tabs defaultValue='all'>
|
||||
<TabsList className='h-full flex-wrap'>
|
||||
<TabsTrigger value='all'>{t('all')}</TabsTrigger>
|
||||
{tags?.map((item) => (
|
||||
<TabsTrigger key={item} value={item}>
|
||||
{item}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value='all'>
|
||||
<DocumentButton items={DocumentList} />
|
||||
</TabsContent>
|
||||
{tags?.map((item) => (
|
||||
<TabsContent value={item} key={item}>
|
||||
<DocumentButton
|
||||
items={DocumentList.filter((docs) => (item ? docs.tags.includes(item) : true))}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
|
||||
{TutorialList && TutorialList?.length > 0 && (
|
||||
<>
|
||||
<h2 className='flex items-center gap-1.5 font-semibold'>{t('tutorial')}</h2>
|
||||
<Tabs defaultValue={TutorialList?.[0]?.title}>
|
||||
<TabsList className='h-full flex-wrap'>
|
||||
{TutorialList?.map((tutorial) => (
|
||||
<TabsTrigger key={tutorial.title} value={tutorial.title}>
|
||||
{tutorial.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{TutorialList?.map((tutorial) => (
|
||||
<TabsContent key={tutorial.title} value={tutorial.title}>
|
||||
<TutorialButton
|
||||
key={tutorial.path}
|
||||
items={
|
||||
tutorial.subItems && tutorial.subItems?.length > 0
|
||||
? tutorial.subItems
|
||||
: [tutorial]
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { getTutorial } from '@/utils/tutorial';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@workspace/ui/components/avatar';
|
||||
import { buttonVariants } from '@workspace/ui/components/button';
|
||||
import { Markdown } from '@workspace/ui/custom-components/markdown';
|
||||
import { useOutsideClick } from '@workspace/ui/hooks/use-outside-click';
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { RefObject, useEffect, useId, useRef, useState } from 'react';
|
||||
import { CloseIcon } from './close-icon';
|
||||
|
||||
interface Item {
|
||||
path: string;
|
||||
title: string;
|
||||
updated_at?: string;
|
||||
icon?: string;
|
||||
}
|
||||
export function TutorialButton({ items }: { items: Item[] }) {
|
||||
const t = useTranslations('document');
|
||||
|
||||
const [active, setActive] = useState<Item | boolean | null>(null);
|
||||
const id = useId();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: !!(active as Item)?.path,
|
||||
queryKey: ['getTutorial', (active as Item)?.path],
|
||||
queryFn: async () => {
|
||||
const markdown = await getTutorial((active as Item)?.path);
|
||||
return markdown;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (active && typeof active === 'object') {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [active]);
|
||||
|
||||
useOutsideClick(ref as RefObject<HTMLDivElement>, () => setActive(null));
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{active && typeof active === 'object' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className='fixed inset-0 z-10 h-full w-full bg-black/20'
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{active && typeof active === 'object' ? (
|
||||
<div className='fixed inset-0 z-[100] grid place-items-center'>
|
||||
<motion.button
|
||||
key={`button-${active.title}-${id}`}
|
||||
layout
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.05,
|
||||
},
|
||||
}}
|
||||
className='bg-foreground absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full text-white dark:text-black'
|
||||
onClick={() => setActive(null)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</motion.button>
|
||||
<motion.div
|
||||
layoutId={`card-${active.title}-${id}`}
|
||||
ref={ref}
|
||||
className='bg-muted flex size-full flex-col overflow-auto p-6 sm:rounded'
|
||||
>
|
||||
<Markdown
|
||||
components={{
|
||||
img: ({ node, className, ...props }) => {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
{...props}
|
||||
width={800}
|
||||
height={384}
|
||||
className='my-4 inline-block size-auto max-h-96'
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{data?.content || ''}
|
||||
</Markdown>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<ul className='flex w-full flex-col gap-4'>
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
layoutId={`card-${item.title}-${id}`}
|
||||
key={`card-${item.title}-${id}`}
|
||||
onClick={() => setActive(item)}
|
||||
className='bg-background hover:bg-accent flex cursor-pointer items-center justify-between rounded border p-4'
|
||||
>
|
||||
<div className='flex flex-row items-center gap-4'>
|
||||
<motion.div layoutId={`image-${item.title}-${id}`}>
|
||||
<Avatar className='size-12'>
|
||||
<AvatarImage alt={item.title ?? ''} src={item.icon ?? ''} />
|
||||
<AvatarFallback className='bg-primary/80 text-white'>
|
||||
{item.title.split('')[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
<div className=''>
|
||||
<motion.h3 layoutId={`title-${item.title}-${id}`} className='font-medium'>
|
||||
{item.title}
|
||||
</motion.h3>
|
||||
{item.updated_at && (
|
||||
<motion.p
|
||||
layoutId={`description-${item.title}-${id}`}
|
||||
className='text-center text-neutral-600 md:text-left dark:text-neutral-400'
|
||||
>
|
||||
{formatDate(new Date(item.updated_at), false)}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
layoutId={`button-${item.title}-${id}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: 'secondary',
|
||||
}),
|
||||
'rounded-full',
|
||||
)}
|
||||
>
|
||||
{t('read')}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Announcement from '@/components/announcement';
|
||||
import { SidebarInset, SidebarProvider } from '@workspace/ui/components/sidebar';
|
||||
import { cookies } from 'next/headers';
|
||||
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='relative p-4'>{children}</SidebarInset>
|
||||
<SidebarRight className='sticky top-[84px] hidden w-52 border-r-0 bg-transparent 2xl:flex' />
|
||||
<Announcement type='popup' Authorization={(await cookies()).get('Authorization')?.value} />
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { Empty } from '@/components/empty';
|
||||
import { ProList, ProListActions } from '@/components/pro-list';
|
||||
import { closeOrder, queryOrderList } from '@/services/user/order';
|
||||
import { Button, buttonVariants } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@workspace/ui/components/card';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
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.OrderDetail, 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.order_no}</p>
|
||||
</CardTitle>
|
||||
<CardDescription className='flex gap-2'>
|
||||
{item.status === 1 ? (
|
||||
<>
|
||||
<Link
|
||||
key='payment'
|
||||
href={`/payment?order_no=${item.order_no}`}
|
||||
className={buttonVariants({ size: 'sm' })}
|
||||
>
|
||||
{t('payment')}
|
||||
</Link>
|
||||
<Button
|
||||
key='cancel'
|
||||
size='sm'
|
||||
variant='destructive'
|
||||
onClick={async () => {
|
||||
await closeOrder({ orderNo: item.order_no });
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
key='detail'
|
||||
href={`/payment?order_no=${item.order_no}`}
|
||||
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>
|
||||
);
|
||||
}}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import StripePayment from '@/components/payment/stripe';
|
||||
import { SubscribeBilling } from '@/components/subscribe/billing';
|
||||
import { SubscribeDetail } from '@/components/subscribe/detail';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { queryOrderDetail } from '@/services/user/order';
|
||||
import { purchaseCheckout } from '@/services/user/portal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@workspace/ui/components/card';
|
||||
import { Separator } from '@workspace/ui/components/separator';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { useCountDown } from 'ahooks';
|
||||
import { addMinutes, format } from 'date-fns';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('order');
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [orderNo, setOrderNo] = useState<string>();
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: enabled,
|
||||
queryKey: ['queryOrderDetail', orderNo],
|
||||
queryFn: async () => {
|
||||
const { data } = await queryOrderDetail({ order_no: orderNo! });
|
||||
if (data?.data?.status !== 1) {
|
||||
getUserInfo();
|
||||
setEnabled(false);
|
||||
}
|
||||
return data?.data;
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: payment } = useQuery({
|
||||
enabled: !!orderNo && data?.status === 1,
|
||||
queryKey: ['purchaseCheckout', orderNo],
|
||||
queryFn: async () => {
|
||||
const { data } = await purchaseCheckout({
|
||||
orderNo: orderNo!,
|
||||
returnUrl: window.location.href,
|
||||
});
|
||||
if (data.data?.type === 'url' && data.data.checkout_url) {
|
||||
window.open(data.data.checkout_url, '_blank');
|
||||
}
|
||||
return data?.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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>{data?.payment.name || data?.payment.platform}</Badge>
|
||||
</dt>
|
||||
</div>
|
||||
</dl>
|
||||
<Separator />
|
||||
|
||||
{data?.type && [1, 2].includes(data.type) && (
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...data?.subscribe,
|
||||
quantity: data?.quantity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{data?.type === 3 && (
|
||||
<>
|
||||
<div className='font-semibold'>{t('resetTraffic')}</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('resetPrice')}
|
||||
</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,
|
||||
unit_price: data?.subscribe?.unit_price,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='order-1 flex flex-auto items-center justify-center xl:order-2'>
|
||||
<CardContent className='py-16'>
|
||||
{data?.status && [2, 5].includes(data?.status) && (
|
||||
<div className='flex flex-col items-center gap-8 text-center'>
|
||||
<h3 className='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 === 'url' && (
|
||||
<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={() => {
|
||||
if (payment?.checkout_url) {
|
||||
window.location.href = payment?.checkout_url;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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: 24,
|
||||
height: 24,
|
||||
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('waitingForPayment')}</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,78 @@
|
||||
'use client';
|
||||
|
||||
import { updateUserPassword } from '@/services/user/user';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form';
|
||||
import { Input } from '@workspace/ui/components/input';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
password: z.string().min(6),
|
||||
repeat_password: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.repeat_password, {
|
||||
message: 'passwordMismatch',
|
||||
path: ['repeat_password'],
|
||||
});
|
||||
|
||||
export default function ChangePassword() {
|
||||
const t = useTranslations('profile.accountSettings');
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
await updateUserPassword({ password: data.password });
|
||||
toast.success(t('updateSuccess'));
|
||||
form.reset();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='min-w-80'>
|
||||
<CardHeader className='bg-muted/50'>
|
||||
<CardTitle className='flex items-center justify-between'>
|
||||
{t('accountSettings')}
|
||||
<Button type='submit' size='sm' form='password-form'>
|
||||
{t('updatePassword')}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='p-6'>
|
||||
<Form {...form}>
|
||||
<form id='password-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { updateUserNotify } from '@/services/user/user';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '@workspace/ui/components/form';
|
||||
import { Switch } from '@workspace/ui/components/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
const FormSchema = z.object({
|
||||
enable_balance_notify: z.boolean().default(false),
|
||||
enable_login_notify: z.boolean().default(false),
|
||||
enable_subscribe_notify: z.boolean().default(false),
|
||||
enable_trade_notify: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export default function NotifySettings() {
|
||||
const t = useTranslations('profile');
|
||||
const { user, getUserInfo } = useGlobalStore();
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
enable_balance_notify: user?.enable_balance_notify ?? false,
|
||||
enable_login_notify: user?.enable_login_notify ?? false,
|
||||
enable_subscribe_notify: user?.enable_subscribe_notify ?? false,
|
||||
enable_trade_notify: user?.enable_trade_notify ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
await updateUserNotify(data);
|
||||
toast.success(t('notify.updateSuccess'));
|
||||
await getUserInfo();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='min-w-80'>
|
||||
<CardHeader className='bg-muted/50'>
|
||||
<CardTitle className='flex items-center justify-between'>
|
||||
{t('notify.notificationSettings')}
|
||||
<Button type='submit' size='sm' form='notify-form'>
|
||||
{t('notify.save')}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-6 p-6'>
|
||||
<Form {...form}>
|
||||
<form id='notify-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<div className='space-y-4'>
|
||||
{[
|
||||
{ name: 'enable_balance_notify', label: 'balanceChange' },
|
||||
{ name: 'enable_login_notify', label: 'login' },
|
||||
{ name: 'enable_subscribe_notify', label: 'subscribe' },
|
||||
{ name: 'enable_trade_notify', label: 'finance' },
|
||||
].map(({ name, label }) => (
|
||||
<FormField
|
||||
key={name}
|
||||
control={form.control}
|
||||
name={name as any}
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex items-center justify-between space-x-4'>
|
||||
<FormLabel className='text-muted-foreground'>
|
||||
{t(`notify.${label}`)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import ChangePassword from './change-password';
|
||||
import NotifySettings from './notify-settings';
|
||||
import ThirdPartyAccounts from './third-party-accounts';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:flex-wrap lg:*:flex-auto'>
|
||||
<ThirdPartyAccounts />
|
||||
<NotifySettings />
|
||||
<ChangePassword />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import SendCode from '@/app/auth/send-code';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { bindOAuth, unbindOAuth, updateBindEmail, updateBindMobile } from '@/services/user/user';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@workspace/ui/components/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form';
|
||||
import { Input } from '@workspace/ui/components/input';
|
||||
import { AreaCodeSelect } from '@workspace/ui/custom-components/area-code-select';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
function MobileBindDialog({
|
||||
onSuccess,
|
||||
children,
|
||||
}: {
|
||||
onSuccess: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useTranslations('profile.thirdParty');
|
||||
const { common } = useGlobalStore();
|
||||
const { enable_whitelist, whitelist } = common.auth.mobile;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
area_code: z.string().min(1, 'Area code is required'),
|
||||
mobile: z.string().min(5, 'Phone number is required'),
|
||||
code: z.string().min(4, 'Verification code is required'),
|
||||
});
|
||||
|
||||
type MobileBindFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<MobileBindFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
area_code: '1',
|
||||
mobile: '',
|
||||
code: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: MobileBindFormValues) => {
|
||||
try {
|
||||
await updateBindMobile(values);
|
||||
toast.success(t('bindSuccess'));
|
||||
onSuccess();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t('bindFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>{children}</DialogTrigger>
|
||||
<DialogContent className='sm:max-w-[425px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('bindMobile')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='mobile'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className='flex'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='area_code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<AreaCodeSelect
|
||||
simple
|
||||
className='w-32 rounded-r-none border-r-0'
|
||||
placeholder='Area code...'
|
||||
value={field.value}
|
||||
whitelist={enable_whitelist ? whitelist : []}
|
||||
onChange={(value) => {
|
||||
if (value.phone) {
|
||||
form.setValue(field.name, value.phone);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
className='rounded-l-none'
|
||||
placeholder='Enter your telephone...'
|
||||
type='tel'
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='code'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className='flex gap-2'>
|
||||
<Input placeholder='Enter code...' type='text' {...field} />
|
||||
<SendCode
|
||||
type='phone'
|
||||
params={{
|
||||
telephone_area_code: form.getValues().area_code,
|
||||
telephone: form.getValues().mobile,
|
||||
type: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type='submit' className='w-full'>
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThirdPartyAccounts() {
|
||||
const t = useTranslations('profile.thirdParty');
|
||||
const { user, getUserInfo, common } = useGlobalStore();
|
||||
const { oauth_methods } = common;
|
||||
|
||||
const accounts = [
|
||||
{
|
||||
id: 'email',
|
||||
icon: 'logos:mailgun-icon',
|
||||
name: 'Email',
|
||||
type: 'Basic',
|
||||
},
|
||||
{
|
||||
id: 'mobile',
|
||||
icon: 'mdi:telephone',
|
||||
name: 'Mobile',
|
||||
type: 'Basic',
|
||||
},
|
||||
{
|
||||
id: 'telegram',
|
||||
icon: 'logos:telegram',
|
||||
name: 'Telegram',
|
||||
type: 'OAuth',
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
icon: 'uil:apple',
|
||||
name: 'Apple',
|
||||
type: 'OAuth',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
icon: 'logos:google',
|
||||
name: 'Google',
|
||||
type: 'OAuth',
|
||||
},
|
||||
{
|
||||
id: 'facebook',
|
||||
icon: 'logos:facebook',
|
||||
name: 'Facebook',
|
||||
type: 'OAuth',
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
icon: 'uil:github',
|
||||
name: 'GitHub',
|
||||
type: 'OAuth',
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
icon: 'mdi:devices',
|
||||
name: 'Device',
|
||||
type: 'OAuth',
|
||||
},
|
||||
].filter((account) => oauth_methods?.includes(account.id));
|
||||
|
||||
const [editValues, setEditValues] = useState<Record<string, any>>({});
|
||||
|
||||
const handleBasicAccountUpdate = async (account: (typeof accounts)[0], value: string) => {
|
||||
if (account.id === 'email') {
|
||||
await updateBindEmail({ email: value });
|
||||
await getUserInfo();
|
||||
toast.success(t('updateSuccess'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountAction = async (account: (typeof accounts)[number]) => {
|
||||
const isBound = user?.auth_methods?.find(
|
||||
(auth) => auth.auth_type === account.id,
|
||||
)?.auth_identifier;
|
||||
if (isBound) {
|
||||
await unbindOAuth({ method: account.id });
|
||||
await getUserInfo();
|
||||
} else {
|
||||
const res = await bindOAuth({
|
||||
method: account.id,
|
||||
redirect: `${window.location.origin}/bind/${account.id}`,
|
||||
});
|
||||
if (res.data?.data?.redirect) {
|
||||
window.location.href = res.data.data.redirect;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className='bg-muted/50'>
|
||||
<CardTitle>{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='p-6'>
|
||||
<div className='space-y-4'>
|
||||
{accounts.map((account) => {
|
||||
const method = user?.auth_methods?.find((auth) => auth.auth_type === account.id);
|
||||
const isEditing = account.id === 'email';
|
||||
const currentValue = method?.auth_identifier || editValues[account.id];
|
||||
let displayValue = '';
|
||||
|
||||
switch (account.id) {
|
||||
case 'email':
|
||||
displayValue = isEditing ? currentValue : method?.auth_identifier || '';
|
||||
break;
|
||||
default:
|
||||
displayValue = method?.auth_identifier || t(`${account.id}.description`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={account.id} className='flex w-full flex-col gap-2'>
|
||||
<span className='flex gap-3 font-medium'>
|
||||
<Icon icon={account.icon} className='size-6' />
|
||||
{account.name}
|
||||
</span>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
value={displayValue}
|
||||
disabled={!isEditing}
|
||||
className='bg-muted flex-1 truncate'
|
||||
onChange={(e) =>
|
||||
isEditing &&
|
||||
setEditValues((prev) => ({ ...prev, [account.id]: e.target.value }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isEditing) {
|
||||
handleBasicAccountUpdate(account, currentValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{account.id === 'mobile' ? (
|
||||
<MobileBindDialog onSuccess={getUserInfo}>
|
||||
<Button
|
||||
variant={method?.auth_identifier ? 'outline' : 'default'}
|
||||
className='whitespace-nowrap'
|
||||
>
|
||||
{t(method?.auth_identifier ? 'update' : 'bind')}
|
||||
</Button>
|
||||
</MobileBindDialog>
|
||||
) : (
|
||||
<Button
|
||||
variant={method?.auth_identifier ? 'outline' : 'default'}
|
||||
onClick={() =>
|
||||
isEditing
|
||||
? handleBasicAccountUpdate(account, currentValue)
|
||||
: handleAccountAction(account)
|
||||
}
|
||||
className='whitespace-nowrap'
|
||||
>
|
||||
{t(isEditing ? 'save' : method?.auth_identifier ? 'unbind' : 'bind')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
import { navs } from '@/config/navs';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@workspace/ui/components/sidebar';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
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,85 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import Recharge from '@/components/subscribe/recharge';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
|
||||
import { Sidebar, SidebarContent } from '@workspace/ui/components/sidebar';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@workspace/ui/components/tooltip';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { isBrowser } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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='space-y-0 p-3 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{t('giftAmount')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='p-3 text-2xl font-bold'>
|
||||
<Display type='currency' value={user?.gift_amount} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className='space-y-0 p-3 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{t('commission')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='p-3 text-2xl font-bold'>
|
||||
<Display type='currency' value={user?.commission} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{user?.refer_code && (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 p-3 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>{t('inviteCode')}</CardTitle>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<CopyToClipboard
|
||||
text={`${isBrowser() && location?.origin}/auth?invite=${user?.refer_code}`}
|
||||
onCopy={(text, result) => {
|
||||
if (result) {
|
||||
toast.success(t('copySuccess'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button variant='ghost' className='size-5 p-0'>
|
||||
<Icon icon='mdi:content-copy' className='text-primary text-2xl' />
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</span>
|
||||
</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,142 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { querySubscribeGroupList, querySubscribeList } from '@/services/user/subscribe';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@workspace/ui/components/card';
|
||||
import { Separator } from '@workspace/ui/components/separator';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Empty } from '@/components/empty';
|
||||
import { SubscribeDetail } from '@/components/subscribe/detail';
|
||||
import Purchase from '@/components/subscribe/purchase';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('subscribe');
|
||||
const [subscribe, setSubscribe] = useState<API.Subscribe>();
|
||||
|
||||
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 && groups.length > 0 && (
|
||||
<>
|
||||
<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';
|
||||
},
|
||||
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: undefined,
|
||||
}}
|
||||
/>
|
||||
</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(item.unit_time || 'Month')}</span>
|
||||
</h2>
|
||||
<Button
|
||||
className='absolute bottom-0 w-full rounded-b-xl rounded-t-none'
|
||||
onClick={() => {
|
||||
setSubscribe(item);
|
||||
}}
|
||||
>
|
||||
{t('buy')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{data?.length === 0 && <Empty />}
|
||||
</Tabs>
|
||||
<Purchase subscribe={subscribe} setSubscribe={setSubscribe} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import { Empty } from '@/components/empty';
|
||||
import { ProList, ProListActions } from '@/components/pro-list';
|
||||
import {
|
||||
createUserTicket,
|
||||
createUserTicketFollow,
|
||||
getUserTicketDetails,
|
||||
getUserTicketList,
|
||||
updateUserTicketStatus,
|
||||
} from '@/services/user/ticket';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@workspace/ui/components/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@workspace/ui/components/dialog';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@workspace/ui/components/drawer';
|
||||
import { Input } from '@workspace/ui/components/input';
|
||||
import { Label } from '@workspace/ui/components/label';
|
||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||
import { Textarea } from '@workspace/ui/components/textarea';
|
||||
import { ConfirmButton } from '@workspace/ui/custom-components/confirm-button';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import NextImage from 'next/legacy/image';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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.Ticket;
|
||||
},
|
||||
enabled: !!ticketId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.children[1]?.scrollTo({
|
||||
top: scrollRef.current.children[1].scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 66);
|
||||
}, [ticket?.follow?.length]);
|
||||
|
||||
const ref = useRef<ProListActions>(null);
|
||||
const [create, setCreate] = useState<Partial<API.CreateUserTicketRequest & { open: boolean }>>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProList<API.Ticket, { status: number }>
|
||||
action={ref}
|
||||
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>
|
||||
);
|
||||
}}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
<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,108 @@
|
||||
'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 } from '@workspace/ui/components/card';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Empty } from '@/components/empty';
|
||||
import Recharge from '@/components/subscribe/recharge';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('wallet');
|
||||
const { user } = useGlobalStore();
|
||||
const ref = useRef<ProListActions>(null);
|
||||
const totalAssets = (user?.balance || 0) + (user?.commission || 0) + (user?.gift_amount || 0);
|
||||
return (
|
||||
<>
|
||||
<Card className='mb-4'>
|
||||
<CardContent className='p-6'>
|
||||
<h2 className='text-foreground mb-4 text-2xl font-bold'>{t('assetOverview')}</h2>
|
||||
<div className='mb-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{t('totalAssets')}</p>
|
||||
<p className='text-3xl font-bold'>
|
||||
<Display type='currency' value={totalAssets} />
|
||||
</p>
|
||||
</div>
|
||||
<Recharge />
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-3'>
|
||||
<div className='bg-secondary rounded-lg p-4 shadow-sm transition-all duration-300 hover:shadow-md'>
|
||||
<p className='text-secondary-foreground text-sm font-medium opacity-80'>
|
||||
{t('balance')}
|
||||
</p>
|
||||
<p className='text-secondary-foreground text-2xl font-bold'>
|
||||
<Display type='currency' value={user?.balance} />
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-secondary rounded-lg p-4 shadow-sm transition-all duration-300 hover:shadow-md'>
|
||||
<p className='text-secondary-foreground text-sm font-medium opacity-80'>
|
||||
{t('giftAmount')}
|
||||
</p>
|
||||
<p className='text-secondary-foreground text-2xl font-bold'>
|
||||
<Display type='currency' value={user?.gift_amount} />
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-secondary rounded-lg p-4 shadow-sm transition-all duration-300 hover:shadow-md'>
|
||||
<p className='text-secondary-foreground text-sm font-medium opacity-80'>
|
||||
{t('commission')}
|
||||
</p>
|
||||
<p className='text-secondary-foreground text-2xl font-bold'>
|
||||
<Display type='currency' value={user?.commission} />
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getPrivacyPolicy } from '@/services/common/common';
|
||||
import { Markdown } from '@workspace/ui/custom-components/markdown';
|
||||
|
||||
export default async function Page() {
|
||||
const { data } = await getPrivacyPolicy();
|
||||
return (
|
||||
<div className='container py-8'>
|
||||
<Markdown>{data.data?.privacy_policy || ''}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { SubscribeBilling } from '@/components/subscribe/billing';
|
||||
import CouponInput from '@/components/subscribe/coupon-input';
|
||||
import { SubscribeDetail } from '@/components/subscribe/detail';
|
||||
import DurationSelector from '@/components/subscribe/duration-selector';
|
||||
import PaymentMethods from '@/components/subscribe/payment-methods';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { prePurchaseOrder, purchase } from '@/services/user/portal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Card, CardContent, CardHeader } from '@workspace/ui/components/card';
|
||||
import { Separator } from '@workspace/ui/components/separator';
|
||||
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { cn } from '@workspace/ui/lib/utils';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState, useTransition } from 'react';
|
||||
|
||||
export default function Content({ subscription }: { subscription?: API.Subscribe }) {
|
||||
const t = useTranslations('subscribe');
|
||||
const { common } = useGlobalStore();
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<API.PortalPurchaseRequest>({
|
||||
quantity: 1,
|
||||
subscribe_id: 0,
|
||||
payment: -1,
|
||||
coupon: '',
|
||||
auth_type: 'email',
|
||||
identifier: '',
|
||||
password: '',
|
||||
});
|
||||
const [loading, startTransition] = useTransition();
|
||||
const [isEmailValid, setIsEmailValid] = useState({
|
||||
valid: false,
|
||||
message: '',
|
||||
});
|
||||
|
||||
const { data: order } = useQuery({
|
||||
enabled: !!subscription?.id && !!params.payment,
|
||||
queryKey: ['preCreateOrder', params.coupon, params.quantity, params.payment],
|
||||
queryFn: async () => {
|
||||
const { data } = await prePurchaseOrder({
|
||||
...params,
|
||||
subscribe_id: subscription?.id as number,
|
||||
} as API.PrePurchaseOrderRequest);
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription) {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
quantity: 1,
|
||||
subscribe_id: subscription?.id,
|
||||
}));
|
||||
}
|
||||
}, [subscription]);
|
||||
|
||||
const handleChange = useCallback((field: keyof typeof params, value: string | number) => {
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const { data } = await purchase(params);
|
||||
const { order_no } = data.data!;
|
||||
if (order_no) {
|
||||
localStorage.setItem(
|
||||
order_no,
|
||||
JSON.stringify({
|
||||
auth_type: params.auth_type,
|
||||
identifier: params.identifier,
|
||||
}),
|
||||
);
|
||||
router.push(`/purchasing/order?order_no=${order_no}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
}, [params, router, subscription?.id]);
|
||||
|
||||
if (!subscription) {
|
||||
return <div className='p-6 text-center'>{t('subscriptionNotFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mx-auto mt-8 flex max-w-4xl flex-col gap-8 md:grid md:grid-cols-2 md:flex-row'>
|
||||
<div className='flex flex-col gap-6'>
|
||||
<Card>
|
||||
<CardHeader>输入要用于 {common.site.site_name} 账户的电子邮件地址</CardHeader>
|
||||
<CardContent className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<EnhancedInput
|
||||
className={cn({
|
||||
'border-destructive': !isEmailValid.valid && params.identifier !== '',
|
||||
})}
|
||||
placeholder='Email'
|
||||
type='email'
|
||||
value={params.identifier || ''}
|
||||
onValueChange={(value) => {
|
||||
const email = value as string;
|
||||
setParams((prev) => ({
|
||||
...prev,
|
||||
identifier: email,
|
||||
}));
|
||||
const reg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!reg.test(email)) {
|
||||
setIsEmailValid({
|
||||
valid: false,
|
||||
message: '请输入有效的邮箱地址',
|
||||
});
|
||||
} else if (common.auth.email.enable_domain_suffix) {
|
||||
const domain = email.split('@')[1];
|
||||
const isValid = common.auth.email?.domain_suffix_list
|
||||
.split('\n')
|
||||
.includes(domain || '');
|
||||
if (!isValid) {
|
||||
setIsEmailValid({
|
||||
valid: false,
|
||||
message: '邮箱域名不在白名单中',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setIsEmailValid({
|
||||
valid: true,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<p
|
||||
className={cn('text-muted-foreground text-xs', {
|
||||
'text-destructive': !isEmailValid.valid && params.identifier !== '',
|
||||
})}
|
||||
>
|
||||
{isEmailValid.message || '请填写您的电子邮件地址。'}
|
||||
</p>
|
||||
</div>
|
||||
{params.identifier && isEmailValid.valid && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<EnhancedInput
|
||||
placeholder='Password'
|
||||
type='password'
|
||||
value={params.password || ''}
|
||||
onValueChange={(value) => handleChange('password', value)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
如果您不填写密码,我们将会自动生成密码并发送到您的邮箱。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* <div>
|
||||
<OAuthMethods />
|
||||
</div> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className='grid gap-3 p-6 text-sm'>
|
||||
<h2 className='text-xl font-semibold'>{subscription.name}</h2>
|
||||
<ul className='flex flex-grow flex-col gap-3'>
|
||||
{(() => {
|
||||
let parsedDescription;
|
||||
try {
|
||||
parsedDescription = JSON.parse(subscription.description);
|
||||
} catch {
|
||||
parsedDescription = { description: '', features: [] };
|
||||
}
|
||||
|
||||
const { description, features } = parsedDescription;
|
||||
return (
|
||||
<>
|
||||
{description && <li className='text-muted-foreground'>{description}</li>}
|
||||
{features?.map(
|
||||
(
|
||||
feature: {
|
||||
icon: string;
|
||||
label: string;
|
||||
type: 'default' | 'success' | 'destructive';
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<li
|
||||
className={cn('flex items-center gap-1', {
|
||||
'text-muted-foreground line-through': feature.type === 'destructive',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{feature.icon && (
|
||||
<Icon
|
||||
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={{
|
||||
...subscription,
|
||||
quantity: params.quantity,
|
||||
}}
|
||||
/>
|
||||
<Separator />
|
||||
<SubscribeBilling
|
||||
order={{
|
||||
...order,
|
||||
quantity: params.quantity,
|
||||
unit_price: subscription?.unit_price,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
<Card>
|
||||
<CardContent className='p-6'>
|
||||
<div className='grid gap-6'>
|
||||
<DurationSelector
|
||||
quantity={params.quantity!}
|
||||
unitTime={subscription?.unit_time}
|
||||
discounts={subscription?.discount}
|
||||
onChange={(value) => handleChange('quantity', value)}
|
||||
/>
|
||||
<CouponInput
|
||||
coupon={params.coupon}
|
||||
onChange={(value) => handleChange('coupon', value)}
|
||||
/>
|
||||
<PaymentMethods
|
||||
balance={false}
|
||||
value={params.payment!}
|
||||
onChange={(value) => handleChange('payment', value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
size='lg'
|
||||
disabled={!isEmailValid.valid || loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading && <LoaderCircle className='mr-2 animate-spin' />}
|
||||
{t('buyNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import StripePayment from '@/components/payment/stripe';
|
||||
import { SubscribeBilling } from '@/components/subscribe/billing';
|
||||
import { SubscribeDetail } from '@/components/subscribe/detail';
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { purchaseCheckout, queryPurchaseOrder } from '@/services/user/portal';
|
||||
import { setAuthorization } from '@/utils/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@workspace/ui/components/card';
|
||||
import { Separator } from '@workspace/ui/components/separator';
|
||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||
import { formatDate } from '@workspace/ui/utils';
|
||||
import { useCountDown } from 'ahooks';
|
||||
import { addMinutes, format } from 'date-fns';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { QRCodeCanvas } from 'qrcode.react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Page() {
|
||||
const t = useTranslations('order');
|
||||
const { getUserInfo } = useGlobalStore();
|
||||
const [orderNo, setOrderNo] = useState<string>();
|
||||
const [enabled, setEnabled] = useState<boolean>(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: enabled,
|
||||
queryKey: ['queryPurchaseOrder', orderNo],
|
||||
queryFn: async () => {
|
||||
if (!orderNo) return;
|
||||
const params = localStorage.getItem(orderNo);
|
||||
const authParams = params ? JSON.parse(params) : {};
|
||||
const { data } = await queryPurchaseOrder({ order_no: orderNo!, ...authParams });
|
||||
if (data?.data?.status !== 1) {
|
||||
setEnabled(false);
|
||||
if (data?.data?.token) {
|
||||
setAuthorization(data?.data?.token);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await getUserInfo();
|
||||
}
|
||||
}
|
||||
return data?.data;
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: payment } = useQuery({
|
||||
enabled: !!orderNo && data?.status === 1,
|
||||
queryKey: ['purchaseCheckout', orderNo],
|
||||
queryFn: async () => {
|
||||
const { data } = await purchaseCheckout({
|
||||
orderNo: orderNo!,
|
||||
returnUrl: window.location.href,
|
||||
});
|
||||
if (data.data?.type === 'url' && data.data?.checkout_url) {
|
||||
window.open(data.data.checkout_url, '_blank');
|
||||
}
|
||||
return data?.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<main className='container lg:mt-16'>
|
||||
<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?.order_no}</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>{data?.payment.name || data?.payment.platform}</Badge>
|
||||
</dt>
|
||||
</div>
|
||||
</dl>
|
||||
<Separator />
|
||||
|
||||
{data?.status && [1, 2].includes(data.status) && (
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...data?.subscribe,
|
||||
quantity: data?.quantity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{data?.status === 3 && (
|
||||
<>
|
||||
<div className='font-semibold'>{t('resetTraffic')}</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('resetPrice')}
|
||||
</span>
|
||||
<span>
|
||||
<Display type='currency' value={data.amount} />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{data?.status === 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,
|
||||
unit_price: data?.subscribe?.unit_price,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='order-1 flex flex-auto items-center justify-center xl:order-2'>
|
||||
<CardContent className='py-16'>
|
||||
{data?.status && [2, 5].includes(data?.status) && (
|
||||
<div className='flex flex-col items-center gap-8 text-center'>
|
||||
<h3 className='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 === 'url' && (
|
||||
<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={() => {
|
||||
if (payment?.checkout_url) {
|
||||
window.location.href = payment?.checkout_url;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('goToPayment')}
|
||||
</Button>
|
||||
<Button variant='outline'>
|
||||
<Link href='/apps/user/public'>{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: 24,
|
||||
height: 24,
|
||||
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('waitingForPayment')}</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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getSubscription } from '@/services/user/portal';
|
||||
import Content from './content';
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}) {
|
||||
const { id } = await searchParams;
|
||||
const { data } = await getSubscription({
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
const subscriptionList = data.data?.list || [];
|
||||
const subscription = subscriptionList.find((item) => item.id === Number(id));
|
||||
|
||||
return (
|
||||
<main className='container space-y-16'>
|
||||
<Content subscription={subscription} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getTos } from '@/services/common/common';
|
||||
import { Markdown } from '@workspace/ui/custom-components/markdown';
|
||||
|
||||
export default async function Page() {
|
||||
const { data } = await getTos();
|
||||
return (
|
||||
<div className='container py-8'>
|
||||
<Markdown>{data.data?.tos_content || ''}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user