✨ feat(ui): Update homepage data
This commit is contained in:
parent
39aaa73d6d
commit
8425b13bce
@ -1,246 +1,15 @@
|
|||||||
'use client';
|
import { GlobalMap } from '@/components/main/global-map';
|
||||||
|
import { Hero } from '@/components/main/hero';
|
||||||
|
import { ProductShowcase } from '@/components/main/product-showcase';
|
||||||
|
import { Stats } from '@/components/main/stats';
|
||||||
|
|
||||||
import useGlobalStore from '@/config/use-global';
|
export default function Home() {
|
||||||
import {
|
|
||||||
GiftIcon,
|
|
||||||
GlobalMapIcon,
|
|
||||||
LocationsIcon,
|
|
||||||
NetworkSecurityIcon,
|
|
||||||
ServersIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from '@repo/ui/lotties';
|
|
||||||
import { Button, buttonVariants } from '@shadcn/ui/button';
|
|
||||||
import { AnimationProps, motion, MotionProps } from 'framer-motion';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
function ScrollAnimationWrapper({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AnimationProps &
|
|
||||||
MotionProps & {
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<motion.section
|
|
||||||
className={className}
|
|
||||||
initial='offscreen'
|
|
||||||
viewport={{ once: true, amount: 0.8 }}
|
|
||||||
whileInView='onscreen'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function getScrollAnimation() {
|
|
||||||
return {
|
|
||||||
offscreen: {
|
|
||||||
y: 150,
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
onscreen: ({ duration = 2 } = {}) => ({
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
type: 'spring',
|
|
||||||
duration,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const listUser = [
|
|
||||||
{
|
|
||||||
name: 'Users',
|
|
||||||
number: '390',
|
|
||||||
icon: <UsersIcon className='size-24' />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Locations',
|
|
||||||
number: '20',
|
|
||||||
icon: <LocationsIcon className='size-24' />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Server',
|
|
||||||
number: '50',
|
|
||||||
icon: <ServersIcon className='size-24' />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const scrollAnimation = useMemo(() => getScrollAnimation(), []);
|
|
||||||
const { common, user } = useGlobalStore();
|
|
||||||
const { site } = common;
|
|
||||||
const t = useTranslations('index');
|
|
||||||
return (
|
return (
|
||||||
<main className='container space-y-16'>
|
<main className='container space-y-16'>
|
||||||
<ScrollAnimationWrapper>
|
<Hero />
|
||||||
<motion.div
|
<Stats />
|
||||||
className='grid grid-flow-row grid-rows-2 gap-8 pt-16 sm:grid-flow-col sm:grid-cols-2 md:grid-rows-1'
|
<ProductShowcase />
|
||||||
variants={scrollAnimation}
|
<GlobalMap />
|
||||||
>
|
|
||||||
<div className='row-start-2 flex flex-col items-start justify-center sm:row-start-1'>
|
|
||||||
<h1 className='my-6 text-pretty text-4xl font-bold lg:text-6xl'>
|
|
||||||
{t('welcome')} {site.site_name}
|
|
||||||
</h1>
|
|
||||||
<p className='text-muted-foreground mb-8 max-w-xl lg:text-xl'>{site.site_desc}</p>
|
|
||||||
<div className='flex w-full flex-col gap-2 sm:flex-row md:justify-start'>
|
|
||||||
<Link href={user ? '/dashboard' : '/auth'} className={buttonVariants()}>
|
|
||||||
{t('started')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex w-full'>
|
|
||||||
<motion.div className='h-full w-full' variants={scrollAnimation}>
|
|
||||||
<NetworkSecurityIcon />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
<ScrollAnimationWrapper>
|
|
||||||
<div className='divide-muted z-10 grid w-full grid-flow-row grid-cols-1 divide-y-2 rounded-lg sm:grid-flow-row sm:grid-cols-3 sm:divide-x-2 sm:divide-y-0'>
|
|
||||||
{listUser.map((item, index) => (
|
|
||||||
<motion.div
|
|
||||||
className='mx-auto flex w-8/12 items-center justify-start px-4 py-4 sm:mx-0 sm:w-auto sm:justify-center sm:py-6'
|
|
||||||
key={index}
|
|
||||||
custom={{ duration: 2 + index }}
|
|
||||||
variants={scrollAnimation}
|
|
||||||
>
|
|
||||||
<div className='mx-auto flex w-40 items-center sm:w-auto'>
|
|
||||||
<div className='mr-6 flex h-24 w-24 items-center justify-center rounded-full'>
|
|
||||||
{item.icon}
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col'>
|
|
||||||
<p className='text-xl font-bold'>{item.number}+</p>
|
|
||||||
<p className='text-muted-foreground text-lg'>{item.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
<div className='mx-auto flex w-full max-w-screen-xl flex-col justify-center px-6 text-center'>
|
|
||||||
<div className='flex w-full flex-col'>
|
|
||||||
<ScrollAnimationWrapper>
|
|
||||||
<motion.h3
|
|
||||||
variants={scrollAnimation}
|
|
||||||
className='text-2xl font-medium leading-relaxed sm:text-3xl lg:text-4xl'
|
|
||||||
>
|
|
||||||
{t('choose_plan')}
|
|
||||||
</motion.h3>
|
|
||||||
<motion.p
|
|
||||||
variants={scrollAnimation}
|
|
||||||
className='mx-auto my-2 w-10/12 text-center leading-normal sm:w-7/12 lg:w-6/12'
|
|
||||||
>
|
|
||||||
{t('choose_plan_desc')}
|
|
||||||
</motion.p>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
<div className='grid grid-flow-row grid-cols-1 gap-4 px-6 py-8 sm:grid-flow-col sm:grid-cols-3 sm:px-0 lg:gap-12 lg:px-6 lg:py-12'>
|
|
||||||
<ScrollAnimationWrapper className='flex justify-center'>
|
|
||||||
<motion.div
|
|
||||||
variants={scrollAnimation}
|
|
||||||
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
|
|
||||||
whileHover={{
|
|
||||||
scale: 1.1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.2,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GiftIcon className='size-48' />
|
|
||||||
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Free Plan</p>
|
|
||||||
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
|
|
||||||
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
|
|
||||||
<li className='check custom-list relative my-2'>Encrypted Connection</li>
|
|
||||||
<li className='check custom-list relative my-2'>No Traffic Logs</li>
|
|
||||||
<li className='check custom-list relative my-2'>Works on All Devices</li>
|
|
||||||
</ul>
|
|
||||||
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
|
|
||||||
<p className='mb-4 text-center text-2xl'>Free</p>
|
|
||||||
<Button>Select</Button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
<ScrollAnimationWrapper className='flex justify-center'>
|
|
||||||
<motion.div
|
|
||||||
variants={scrollAnimation}
|
|
||||||
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
|
|
||||||
whileHover={{
|
|
||||||
scale: 1.1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.2,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GiftIcon className='size-48' />
|
|
||||||
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Standard Plan </p>
|
|
||||||
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
|
|
||||||
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
|
|
||||||
<li className='check custom-list relative my-2'>Encrypted Connection</li>
|
|
||||||
<li className='check custom-list relative my-2'>No Traffic Logs</li>
|
|
||||||
<li className='check custom-list relative my-2'>Works on All Devices</li>
|
|
||||||
<li className='check custom-list relative my-2'>Connect Anyware </li>
|
|
||||||
</ul>
|
|
||||||
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
|
|
||||||
<p className='mb-4 text-center text-2xl'>
|
|
||||||
$9 <span className='text-muted-foreground'>/ mo</span>
|
|
||||||
</p>
|
|
||||||
<Button>Select</Button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
<ScrollAnimationWrapper className='flex justify-center'>
|
|
||||||
<motion.div
|
|
||||||
variants={scrollAnimation}
|
|
||||||
className='flex flex-col items-center justify-center rounded-xl border-2 border-gray-500 px-6 py-4 lg:px-12 xl:px-20'
|
|
||||||
whileHover={{
|
|
||||||
scale: 1.1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.2,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GiftIcon className='size-48' />
|
|
||||||
<p className='my-2 text-lg font-medium capitalize sm:my-7'>Premium Plan </p>
|
|
||||||
<ul className='text-muted-foreground flex flex-grow list-inside flex-col items-start justify-start pl-6 text-left xl:pl-0'>
|
|
||||||
<li className='check custom-list relative my-2'>Unlimited Bandwitch</li>
|
|
||||||
<li className='check custom-list relative my-2'>Encrypted Connection</li>
|
|
||||||
<li className='check custom-list relative my-2'>No Traffic Logs</li>
|
|
||||||
<li className='check custom-list relative my-2'>Works on All Devices</li>
|
|
||||||
<li className='check custom-list relative my-2'>Connect Anyware </li>
|
|
||||||
<li className='check custom-list relative my-2'>Get New Features </li>
|
|
||||||
</ul>
|
|
||||||
<div className='mb-8 mt-12 flex w-full flex-none flex-col justify-center'>
|
|
||||||
<p className='mb-4 text-center text-2xl'>
|
|
||||||
$12 <span className='text-muted-foreground'>/ mo</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button>Select</Button>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ScrollAnimationWrapper>
|
|
||||||
<motion.h3
|
|
||||||
variants={scrollAnimation}
|
|
||||||
className='mx-auto text-center text-2xl font-medium leading-relaxed sm:text-3xl lg:text-4xl'
|
|
||||||
>
|
|
||||||
{t('huge_network')} {site.site_name}
|
|
||||||
</motion.h3>
|
|
||||||
<motion.p className='mx-auto my-2 text-center leading-normal' variants={scrollAnimation}>
|
|
||||||
{site.site_name} {t('global_network_desc')}
|
|
||||||
</motion.p>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
<ScrollAnimationWrapper>
|
|
||||||
<motion.div className='aspect-[2/1] w-full overflow-hidden' variants={scrollAnimation}>
|
|
||||||
<GlobalMapIcon className='-mt-[25%] w-full' />
|
|
||||||
</motion.div>
|
|
||||||
</ScrollAnimationWrapper>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
apps/user/components/main/global-map.tsx
Normal file
47
apps/user/components/main/global-map.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/user/components/main/hero.tsx
Normal file
61
apps/user/components/main/hero.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
apps/user/components/main/product-showcase.tsx
Normal file
133
apps/user/components/main/product-showcase.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
apps/user/components/main/stats.tsx
Normal file
104
apps/user/components/main/stats.tsx
Normal file
@ -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()}</>;
|
||||||
|
}
|
||||||
@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"choose_plan": "Choose Your Plan",
|
"global_map_description": "Explore seamless global connectivity. Choose network services that suit your needs and stay connected anytime, anywhere.",
|
||||||
"choose_plan_desc": "Let's select the plan that best suits you and explore it happily.",
|
"global_map_title": "Global Connectivity, Effortless Peace of Mind",
|
||||||
"global_network_desc": "Easily access our services while on the move.",
|
"locations": "Locations",
|
||||||
"huge_network": "Vast and Fast Global Network",
|
"per_month": "Per Month",
|
||||||
|
"product_showcase_description": "Let us help you select the package that best suits you and enjoy exploring it.",
|
||||||
|
"product_showcase_title": "Choose Your Package",
|
||||||
|
"servers": "Servers",
|
||||||
"started": "Get Started",
|
"started": "Get Started",
|
||||||
|
"subscribe": "Subscribe",
|
||||||
"tos": "Terms of Service",
|
"tos": "Terms of Service",
|
||||||
|
"users": "Users",
|
||||||
"welcome": "Welcome to"
|
"welcome": "Welcome to"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"choose_plan": "选择您的套餐",
|
"global_map_description": "探索无缝的全球连接。选择符合您需求的网络服务,随时随地保持连接。",
|
||||||
"choose_plan_desc": "让我们选择最适合您的套餐,快乐地探索它。",
|
"global_map_itle": "全球互联,轻松无忧",
|
||||||
"global_network_desc": "让您在移动地点时更轻松地看到我们的服务。",
|
"locations": "地区",
|
||||||
"huge_network": "庞大的快速全球网络",
|
"per_month": "每月",
|
||||||
|
"product_showcase_description": "让我们选择最适合您的套餐,快乐地探索它。",
|
||||||
|
"product_showcase_title": "选择您的套餐",
|
||||||
|
"servers": "服务器",
|
||||||
"started": "开始使用",
|
"started": "开始使用",
|
||||||
|
"subscribe": "订阅",
|
||||||
"tos": "服务协议",
|
"tos": "服务协议",
|
||||||
|
"users": "用户",
|
||||||
"welcome": "欢迎来到"
|
"welcome": "欢迎来到"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ requset.interceptors.request.use(
|
|||||||
async (
|
async (
|
||||||
config: InternalAxiosRequestConfig & {
|
config: InternalAxiosRequestConfig & {
|
||||||
Authorization?: string;
|
Authorization?: string;
|
||||||
|
skipErrorHandler?: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const Authorization = getAuthorization(config.Authorization);
|
const Authorization = getAuthorization(config.Authorization);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user