fix: demo首页

This commit is contained in:
2025-07-25 06:45:54 -07:00
parent 3e18012b1b
commit 84cd214dc5
36 changed files with 234 additions and 13 deletions
@@ -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 />}
/>
</>
);
}