✨ feat(ui): Update homepage data
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { GlobalMapIcon } from '@repo/ui/lotties';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function GlobalMap() {
|
||||
const t = useTranslations('index');
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className='mb-2 text-center text-3xl font-bold'
|
||||
>
|
||||
{t('global_map_itle')}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className='text-muted-foreground mb-8 text-center text-lg'
|
||||
>
|
||||
{t('global_map_description')}
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className='aspect-video w-full overflow-hidden'
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 100,
|
||||
damping: 15,
|
||||
delay: 0.4,
|
||||
}}
|
||||
>
|
||||
<GlobalMapIcon className='-mt-[25%] w-full' />
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import useGlobalStore from '@/config/use-global';
|
||||
import { NetworkSecurityIcon } from '@repo/ui/lotties';
|
||||
import { HoverBorderGradient } from '@shadcn/ui/hover-border-gradient';
|
||||
import { TextGenerateEffect } from '@shadcn/ui/text-generate-effect';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function Hero() {
|
||||
const t = useTranslations('index');
|
||||
const { common, user } = useGlobalStore();
|
||||
const { site } = common;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 100, damping: 20 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
className='grid gap-8 pt-16 sm:grid-cols-2'
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 80, damping: 15, delay: 0.3 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
className='flex flex-col items-start justify-center'
|
||||
>
|
||||
<h1 className='my-6 text-4xl font-bold lg:text-6xl'>
|
||||
{t('welcome')} {site.site_name}
|
||||
</h1>
|
||||
{site.site_desc && (
|
||||
<TextGenerateEffect
|
||||
words={site.site_desc}
|
||||
className='*:text-muted-foreground mb-8 max-w-xl'
|
||||
/>
|
||||
)}
|
||||
<Link href={user ? '/dashboard' : '/auth'}>
|
||||
<HoverBorderGradient
|
||||
containerClassName='rounded-full'
|
||||
as='button'
|
||||
className='bg-background text-foreground flex items-center space-x-2'
|
||||
>
|
||||
{t('started')}
|
||||
</HoverBorderGradient>
|
||||
</Link>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 80, damping: 15, delay: 0.5 }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
className='flex w-full'
|
||||
>
|
||||
<NetworkSecurityIcon />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { SubscribeDetail } from '@/app/(main)/(user)/subscribe/detail';
|
||||
import { Display } from '@/components/display';
|
||||
import { getSubscription } from '@/services/common/common';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@shadcn/ui/card';
|
||||
import { cn } from '@shadcn/ui/lib/utils';
|
||||
import { Separator } from '@shadcn/ui/separator';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function ProductShowcase() {
|
||||
const t = useTranslations('index');
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['getSubscription'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscription({
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
if (data?.length === 0) return null;
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className='mb-2 text-center text-3xl font-bold'
|
||||
>
|
||||
{t('product_showcase_title')}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className='text-muted-foreground mb-8 text-center text-lg'
|
||||
>
|
||||
{t('product_showcase_description')}
|
||||
</motion.p>
|
||||
<div className='mx-auto flex flex-wrap justify-center gap-8 overflow-x-auto overflow-y-hidden'>
|
||||
{data?.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.5 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className='w-1/2 lg:w-1/4'
|
||||
>
|
||||
<Card className='flex flex-col overflow-hidden rounded-lg shadow-lg transition-shadow duration-300 hover:shadow-2xl'>
|
||||
<CardHeader className='bg-muted/50 p-4 text-xl font-medium'>{item.name}</CardHeader>
|
||||
<CardContent className='flex flex-grow flex-col gap-4 p-6 text-sm'>
|
||||
<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, index) => (
|
||||
<li
|
||||
className={cn('flex items-center gap-2', {
|
||||
'text-muted-foreground line-through': feature.type === 'destructive',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{feature.icon && (
|
||||
<Icon
|
||||
icon={feature.icon}
|
||||
className={cn('text-primary size-5', {
|
||||
'text-green-500': feature.type === 'success',
|
||||
'text-destructive': feature.type === 'destructive',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{feature.label}
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ul>
|
||||
<SubscribeDetail
|
||||
subscribe={{
|
||||
...item,
|
||||
name: null,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<Separator />
|
||||
<CardFooter className='relative flex flex-col gap-4 p-4'>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className='pb-4 text-2xl font-semibold sm:text-3xl'
|
||||
>
|
||||
<Display type='currency' value={item.unit_price} />
|
||||
<span className='text-base font-medium'>/{t('per_month')}</span>
|
||||
</motion.h2>
|
||||
<motion.div>
|
||||
<Button
|
||||
className='absolute bottom-0 left-0 w-full rounded-b-xl rounded-t-none'
|
||||
onClick={() => {}}
|
||||
>
|
||||
{t('subscribe')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { getStat } from '@/services/common/common';
|
||||
import { LocationsIcon, ServersIcon, UsersIcon } from '@repo/ui/lotties';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function Stats() {
|
||||
const t = useTranslations('index');
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['getStat'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getStat({
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const list = [
|
||||
{
|
||||
name: t('users'),
|
||||
number: data?.user || 999,
|
||||
icon: <UsersIcon className='size-24' />,
|
||||
},
|
||||
{
|
||||
name: t('servers'),
|
||||
number: data?.server || 30,
|
||||
icon: <ServersIcon className='size-24' />,
|
||||
},
|
||||
{
|
||||
name: t('locations'),
|
||||
number: data?.country || 10,
|
||||
icon: <LocationsIcon className='size-24' />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<motion.section
|
||||
className='divide-muted z-10 grid w-full grid-cols-1 divide-y-2 rounded-lg sm:grid-cols-3 sm:divide-x-2 sm:divide-y-0'
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, ease: 'easeOut' }}
|
||||
viewport={{ once: true, amount: 0.8 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{list.map((item, index) => (
|
||||
<motion.div
|
||||
className='mx-auto flex w-10/12 items-center justify-start px-4 py-4 sm:w-full sm:justify-center sm:py-6'
|
||||
key={item.name}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: index * 0.3, ease: 'easeOut' }}
|
||||
viewport={{ once: true, amount: 0.8 }}
|
||||
>
|
||||
<div className='flex w-full items-center sm:w-auto'>
|
||||
<div className='mr-4 flex h-20 w-20 items-center justify-center rounded-full'>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<p className='text-xl font-bold'>
|
||||
<CountUp end={item.number} duration={2000 + index * 500} />+
|
||||
</p>
|
||||
<p className='text-muted-foreground text-lg'>{item.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
function CountUp({ end, duration = 2000 }: { end: number; duration?: number }) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let startTime;
|
||||
let animationFrame;
|
||||
|
||||
const easeOutQuad = (t) => t * (2 - t);
|
||||
|
||||
const updateCount = (timestamp) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = timestamp - startTime;
|
||||
const easedProgress = easeOutQuad(Math.min(progress / duration, 1));
|
||||
const nextCount = Math.round(easedProgress * end);
|
||||
|
||||
setCount(nextCount);
|
||||
|
||||
if (progress < duration) {
|
||||
animationFrame = requestAnimationFrame(updateCount);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(updateCount);
|
||||
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [end, duration]);
|
||||
|
||||
return <>{count.toLocaleString()}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user