BaiLuoYan před 3 týdny
rodič
revize
6823560ecc

+ 1 - 1
index.html

@@ -2,7 +2,7 @@
 <html lang="en">
     <head>
         <meta charset="UTF-8" />
-        <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+        <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
         <meta name="viewport" content="width=device-width, initial-scale=1.0" />
         <title>%VITE_APP_TITLE%</title>
     </head>

binární
public/favicon.ico


+ 0 - 1
public/vite.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 12 - 0
src/assets/iconify/multi-color/feature-coverage.svg

@@ -0,0 +1,12 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 14C0 6.26801 6.26801 0 14 0H34C41.732 0 48 6.26801 48 14V34C48 41.732 41.732 48 34 48H14C6.26801 48 0 41.732 0 34V14Z" fill="url(#paint0_linear_1769_2518)"/>
+<path d="M24 34C29.5228 34 34 29.5228 34 24C34 18.4772 29.5228 14 24 14C18.4772 14 14 18.4772 14 24C14 29.5228 18.4772 34 24 34Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M24 14C21.4322 16.6962 20 20.2767 20 24C20 27.7233 21.4322 31.3038 24 34C26.5678 31.3038 28 27.7233 28 24C28 20.2767 26.5678 16.6962 24 14Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14 24H34" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1769_2518" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse">
+<stop stop-color="#AD46FF" style="stop-color:#AD46FF;stop-color:color(display-p3 0.6779 0.2759 1.0000);stop-opacity:1;"/>
+<stop offset="1" stop-color="#F6339A" style="stop-color:#F6339A;stop-color:color(display-p3 0.9658 0.1981 0.6043);stop-opacity:1;"/>
+</linearGradient>
+</defs>
+</svg>

+ 10 - 0
src/assets/iconify/multi-color/feature-encryption.svg

@@ -0,0 +1,10 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 14C0 6.26801 6.26801 0 14 0H34C41.732 0 48 6.26801 48 14V34C48 41.732 41.732 48 34 48H14C6.26801 48 0 41.732 0 34V14Z" fill="url(#paint0_linear_1769_2528)"/>
+<path d="M32 25C32 30 28.5 32.5 24.34 33.95C24.1222 34.0238 23.8855 34.0202 23.67 33.94C19.5 32.5 16 30 16 25V18C16 17.7347 16.1054 17.4804 16.2929 17.2929C16.4804 17.1053 16.7348 17 17 17C19 17 21.5 15.8 23.24 14.28C23.4519 14.099 23.7214 13.9995 24 13.9995C24.2786 13.9995 24.5481 14.099 24.76 14.28C26.51 15.81 29 17 31 17C31.2652 17 31.5196 17.1053 31.7071 17.2929C31.8946 17.4804 32 17.7347 32 18V25Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1769_2528" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse">
+<stop stop-color="#46FFBE" style="stop-color:#46FFBE;stop-color:color(display-p3 0.2759 1.0000 0.7466);stop-opacity:1;"/>
+<stop offset="1" stop-color="#F6D233" style="stop-color:#F6D233;stop-color:color(display-p3 0.9658 0.8251 0.1981);stop-opacity:1;"/>
+</linearGradient>
+</defs>
+</svg>

+ 10 - 0
src/assets/iconify/multi-color/feature-speed.svg

@@ -0,0 +1,10 @@
+<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 14C0 6.26801 6.26801 0 14 0H34C41.732 0 48 6.26801 48 14V34C48 41.732 41.732 48 34 48H14C6.26801 48 0 41.732 0 34V14Z" fill="url(#paint0_linear_1769_2510)"/>
+<path d="M16.0005 26C15.8112 26.0007 15.6257 25.9476 15.4654 25.847C15.3052 25.7464 15.1767 25.6024 15.095 25.4317C15.0133 25.261 14.9818 25.0706 15.004 24.8827C15.0262 24.6948 15.1013 24.517 15.2205 24.37L25.1205 14.17C25.1947 14.0843 25.2959 14.0264 25.4075 14.0058C25.519 13.9852 25.6342 14.0031 25.7342 14.0565C25.8342 14.11 25.9131 14.1959 25.9578 14.3001C26.0026 14.4044 26.0106 14.5207 25.9805 14.63L24.0605 20.65C24.0039 20.8016 23.9849 20.9646 24.0051 21.125C24.0253 21.2855 24.0841 21.4387 24.1766 21.5715C24.269 21.7042 24.3923 21.8126 24.5358 21.8872C24.6793 21.9618 24.8387 22.0006 25.0005 22H32.0005C32.1897 21.9994 32.3752 22.0525 32.5355 22.1531C32.6958 22.2537 32.8242 22.3977 32.9059 22.5684C32.9876 22.7391 33.0192 22.9295 32.997 23.1174C32.9748 23.3053 32.8997 23.4831 32.7805 23.63L22.8805 33.83C22.8062 33.9158 22.705 33.9737 22.5935 33.9943C22.482 34.0149 22.3668 33.997 22.2668 33.9435C22.1667 33.89 22.0879 33.8041 22.0431 33.6999C21.9984 33.5957 21.9904 33.4794 22.0205 33.37L23.9405 27.35C23.9971 27.1985 24.0161 27.0355 23.9959 26.875C23.9757 26.7145 23.9168 26.5614 23.8244 26.4286C23.732 26.2959 23.6087 26.1875 23.4652 26.1129C23.3217 26.0382 23.1622 25.9995 23.0005 26H16.0005Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<defs>
+<linearGradient id="paint0_linear_1769_2510" x1="0" y1="0" x2="48" y2="48" gradientUnits="userSpaceOnUse">
+<stop stop-color="#467BFF" style="stop-color:#467BFF;stop-color:color(display-p3 0.2759 0.4811 1.0000);stop-opacity:1;"/>
+<stop offset="1" stop-color="#33DFF6" style="stop-color:#33DFF6;stop-color:color(display-p3 0.1981 0.8762 0.9658);stop-opacity:1;"/>
+</linearGradient>
+</defs>
+</svg>

binární
src/assets/images/home/hero-app-store.png


binární
src/assets/images/home/hero-bg.png


binární
src/assets/images/home/hero-devices.png


binární
src/assets/images/home/hero-google-play.png


+ 45 - 0
src/locales/en-US/pages.ts

@@ -17,6 +17,51 @@ export default {
         },
     },
 
+    home: {
+        hero: {
+            title: 'Browse Without\nBoundaries',
+            subtitle:
+                'Secure, fast, and private VPN service with military-grade encryption. Browse the internet freely with global coverage.',
+        },
+        features: {
+            title: 'Why Choose NOMO VPN?',
+            subtitle:
+                'Experience the next generation of VPN technology with our cutting-edge features',
+            speed: {
+                title: 'Lightning Fast Speed',
+                description:
+                    'Experience blazing-fast connection speeds with our optimized server network. Stream, game, and browse without buffering.',
+            },
+            coverage: {
+                title: 'Global Coverage',
+                description:
+                    'Access content from anywhere with our 5000+ servers in 60+ countries. Connect to the location that works best for you.',
+            },
+            encryption: {
+                title: 'Military-Grade Encryption',
+                description:
+                    'Your data is protected with AES-256 encryption. We maintain a strict no-logs policy to ensure your privacy is always protected.',
+            },
+        },
+        pricing: {
+            title: 'Choose Your Plan',
+            subtitle:
+                'Select the perfect plan for your needs. All plans include our core security features.',
+            selectPlan: 'Get Started',
+        },
+        download: {
+            title: 'Get Started Today',
+            subtitle:
+                'Available for iOS, Android, and Windows soon. Download NOMO VPN and experience true online freedom.',
+            downloadsCount: '1M+',
+            downloadsLabel: 'Downloads',
+            appStoreRating: '4.8★',
+            appStoreLabel: 'App Store',
+            googlePlayRating: '4.7★',
+            googlePlayLabel: 'Google Play',
+        },
+    },
+
     pricing: {
         title: 'Purchase NOMO VPN Plan',
         selecPlan: 'Select a plan that suits you',

+ 44 - 0
src/locales/fa-IR/pages.ts

@@ -17,6 +17,50 @@ export default {
         },
     },
 
+    home: {
+        hero: {
+            title: 'مرور بدون\nمرز',
+            subtitle:
+                'سرویس VPN امن، سریع و خصوصی با رمزنگاری سطح نظامی. با پوشش جهانی آزادانه در اینترنت گشت‌زنی کنید.',
+        },
+        features: {
+            title: 'چرا NOMO VPN؟',
+            subtitle: 'نسل بعدی فناوری VPN را با امکانات پیشرفته تجربه کنید',
+            speed: {
+                title: 'سرعت برق‌آسا',
+                description:
+                    'با شبکه سرورهای بهینه‌شده از اتصال فوق‌سریع لذت ببرید. استریم، بازی و مرور بدون بافرینگ.',
+            },
+            coverage: {
+                title: 'پوشش جهانی',
+                description:
+                    'با بیش از ۵۰۰۰ سرور در بیش از ۶۰ کشور از هرجا به محتوا دسترسی داشته باشید. به بهترین موقعیت برای خود متصل شوید.',
+            },
+            encryption: {
+                title: 'رمزنگاری سطح نظامی',
+                description:
+                    'داده‌های شما با رمزنگاری AES-256 محافظت می‌شوند. ما سیاست بدون ذخیره لاگ را رعایت می‌کنیم تا حریم خصوصی شما همیشه در امان باشد.',
+            },
+        },
+        pricing: {
+            title: 'پلن خود را انتخاب کنید',
+            subtitle:
+                'مناسب‌ترین پلن را برای نیاز خود انتخاب کنید. همه پلن‌ها شامل امکانات امنیتی اصلی هستند.',
+            selectPlan: 'خرید',
+        },
+        download: {
+            title: 'همین امروز شروع کنید',
+            subtitle:
+                'به‌زودی برای iOS، اندروید و ویندوز. NOMO VPN را دانلود کنید و آزادی واقعی آنلاین را تجربه کنید.',
+            downloadsCount: '۱ میلیون+',
+            downloadsLabel: 'دانلود',
+            appStoreRating: '۴.۸★',
+            appStoreLabel: 'اپ استور',
+            googlePlayRating: '۴.۷★',
+            googlePlayLabel: 'گوگل پلی',
+        },
+    },
+
     pricing: {
         title: 'خرید پلن NOMO VPN',
         selecPlan: 'یک پلن را انتخاب کنید که به شما مناسب است',

+ 43 - 0
src/locales/zh-CN/pages.ts

@@ -17,6 +17,49 @@ export default {
         },
     },
 
+    home: {
+        hero: {
+            title: '畅游无界',
+            subtitle:
+                '安全、快速、私密的 VPN 服务,采用军用级加密。全球覆盖,自由畅享互联网。',
+        },
+        features: {
+            title: '为什么选择 NOMO VPN?',
+            subtitle: '体验新一代 VPN 技术,畅享前沿功能',
+            speed: {
+                title: '极速连接',
+                description:
+                    '依托优化服务器网络,畅享极速连接。追剧、游戏、浏览,告别卡顿。',
+            },
+            coverage: {
+                title: '全球覆盖',
+                description:
+                    '60+ 国家/地区、5000+ 服务器,随时随地访问内容,连接最适合你的节点。',
+            },
+            encryption: {
+                title: '军用级加密',
+                description:
+                    'AES-256 加密保护你的数据。我们坚持零日志政策,始终守护你的隐私。',
+            },
+        },
+        pricing: {
+            title: '选择你的套餐',
+            subtitle: '根据需求选择最合适的方案,所有套餐均包含核心安全功能。',
+            selectPlan: '去购买',
+        },
+        download: {
+            title: '立即开始使用',
+            subtitle:
+                '即将支持 iOS、Android 与 Windows。下载 NOMO VPN,畅享真正的上网自由。',
+            downloadsCount: '100 万+',
+            downloadsLabel: '次下载',
+            appStoreRating: '4.8★',
+            appStoreLabel: 'App Store',
+            googlePlayRating: '4.7★',
+            googlePlayLabel: 'Google Play',
+        },
+    },
+
     pricing: {
         title: '购买 NOMO VPN 套餐',
         selecPlan: '选择一个适合您的套餐',

+ 62 - 0
src/pages/home/components/Download.tsx

@@ -0,0 +1,62 @@
+import { useTranslation } from 'react-i18next';
+
+import { useAppUrls } from '@/hooks/useAppUrls';
+
+import heroAppStore from '@/assets/images/home/hero-app-store.png';
+import heroGooglePlay from '@/assets/images/home/hero-google-play.png';
+
+import { DownloadButton } from './DownloadButton';
+import Wrapper from './Wrapper';
+
+export function Download() {
+    const { t } = useTranslation();
+    const { appleStoreUrl, downloadUrlByPlatform } = useAppUrls();
+
+    const items = [
+        {
+            titleKey: 'pages.home.download.downloadsCount',
+            descKey: 'pages.home.download.downloadsLabel',
+        },
+        {
+            titleKey: 'pages.home.download.appStoreRating',
+            descKey: 'pages.home.download.appStoreLabel',
+        },
+        {
+            titleKey: 'pages.home.download.googlePlayRating',
+            descKey: 'pages.home.download.googlePlayLabel',
+        },
+    ] as const;
+
+    return (
+        <section className="w-full py-10 sm:py-20 mt-20 sm:mt-5 lg:mt-20">
+            <Wrapper className="text-center">
+                <h2 className="text-2xl sm:text-[32px] font-medium text-white leading-[1.25] sm:leading-[0.9375]">
+                    {t('pages.home.download.title')}
+                </h2>
+                <p className="text-base mt-4 sm:mt-[18px] text-white/60 sm:text-white/90 max-w-[632px] mx-auto leading-[1.5]">
+                    {t('pages.home.download.subtitle')}
+                </p>
+                <div className="mt-10 sm:mt-12 flex flex-col sm:flex-row items-center justify-center gap-[27px] sm:gap-4">
+                    <DownloadButton
+                        image={heroAppStore}
+                        url={appleStoreUrl ?? ''}
+                        className="h-[67px]"
+                    />
+                    <DownloadButton
+                        image={heroGooglePlay}
+                        url={downloadUrlByPlatform ?? ''}
+                        className="h-[67px]"
+                    />
+                </div>
+                <div className="mt-10 sm:mt-12 flex flex-wrap justify-center items-center gap-6 sm:gap-12 text-base">
+                    {items.map((item, index) => (
+                        <div className="flex flex-col items-center gap-1 text-base" key={index}>
+                            <span className="font-semibold text-white/80">{t(item.titleKey)}</span>
+                            <span className="text-white/60">{t(item.descKey)}</span>
+                        </div>
+                    ))}
+                </div>
+            </Wrapper>
+        </section>
+    );
+}

+ 23 - 0
src/pages/home/components/DownloadButton.tsx

@@ -0,0 +1,23 @@
+interface DownloadButtonProps {
+    image: string;
+    url: string;
+    alt?: string;
+    /** 图片高度样式,如 h-[50px],不传则默认 h-[34px] */
+    className?: string;
+}
+
+const DEFAULT_IMG_CLASS = 'h-[34px]';
+
+export function DownloadButton({ image, url, alt = '', className }: DownloadButtonProps) {
+    if (!url) return null;
+
+    return (
+        <a href={url} target="_blank" rel="noopener noreferrer">
+            <img
+                src={image}
+                alt={alt}
+                className={`${className ?? DEFAULT_IMG_CLASS} sm:h-[50px] w-auto object-contain block`}
+            />
+        </a>
+    );
+}

+ 64 - 0
src/pages/home/components/Features.tsx

@@ -0,0 +1,64 @@
+import { useTranslation } from 'react-i18next';
+
+import { Icon } from '@iconify/react';
+
+import featureCoverage from '@/assets/iconify/multi-color/feature-coverage.svg';
+import featureEncryption from '@/assets/iconify/multi-color/feature-encryption.svg';
+import featureSpeed from '@/assets/iconify/multi-color/feature-speed.svg';
+import Wrapper from './Wrapper';
+
+export function Features() {
+    const { t } = useTranslation();
+
+    const items = [
+        {
+            titleKey: 'pages.home.features.speed.title',
+            descKey: 'pages.home.features.speed.description',
+            icon: featureSpeed,
+        },
+        {
+            titleKey: 'pages.home.features.coverage.title',
+            descKey: 'pages.home.features.coverage.description',
+            icon: featureCoverage,
+        },
+        {
+            titleKey: 'pages.home.features.encryption.title',
+            descKey: 'pages.home.features.encryption.description',
+            icon: featureEncryption,
+        },
+    ] as const;
+
+    return (
+        <section className="w-full bg-gradient-to-b from-black to-[#030712] pt-12 sm:pt-24 lg:pt-4 pb-20">
+            <Wrapper>
+                <div className="text-center max-w-[672px] mx-auto mb-10 sm:mb-16">
+                    <h2 className="text-2xl sm:text-[32px] font-medium text-white leading-[0.94]">
+                        {t('pages.home.features.title')}
+                    </h2>
+                    <p className="mt-4 text-white/60 text-base text-center leading-[1.5]">
+                        {t('pages.home.features.subtitle')}
+                    </p>
+                </div>
+            </Wrapper>
+            <div className="w-full sm:max-w-full sm:overflow-x-scroll sm:no-scrollbar">
+                <Wrapper className="w-fit min-w-full flex flex-wrap sm:flex-nowrap items-stretch gap-8">
+                    {items.map(({ titleKey, descKey, icon }, index) => (
+                        <div
+                            key={index}
+                            className="rounded-2xl border border-white/10 bg-white/5 sm:min-h-[246px] w-full sm:w-[406px] p-5 sm:p-8 shrink-0 flex flex-col"
+                        >
+                            <Icon
+                                icon={icon}
+                                className="w-12 h-12 shrink-0 rounded-[14px] mb-5 sm:mb-6"
+                            />
+                            <h3 className="text-base font-semibold text-white">{t(titleKey)}</h3>
+                            <p className="mt-[10px] sm:mt-3 text-white/60 text-base leading-[1.5] flex-1">
+                                {t(descKey)}
+                            </p>
+                        </div>
+                    ))}
+                </Wrapper>
+            </div>
+        </section>
+    );
+}

+ 51 - 0
src/pages/home/components/Hero.tsx

@@ -0,0 +1,51 @@
+import { useTranslation } from 'react-i18next';
+
+import { useAppUrls } from '@/hooks/useAppUrls';
+
+import heroAppStore from '@/assets/images/home/hero-app-store.png';
+import heroBg from '@/assets/images/home/hero-bg.png';
+import heroDevices from '@/assets/images/home/hero-devices.png';
+import heroGooglePlay from '@/assets/images/home/hero-google-play.png';
+
+import { DownloadButton } from './DownloadButton';
+import Wrapper from './Wrapper';
+
+export function Hero() {
+    const { t } = useTranslation();
+    const { appleStoreUrl, downloadUrlByPlatform } = useAppUrls();
+
+    return (
+        <section className="relative overflow-hidden w-full">
+            <img
+                src={heroBg}
+                className="abs-tc !max-w-none w-[calc(100%+140px)] !top-[140px] sm:!top-[260px] lg:!top-[155px] lg:!left-[calc(50%+96px)] lg:!w-[78%] object-cover object-center"
+            />
+            <Wrapper className="z-10">
+                <div className="relative w-full pt-[30px] sm:pt-[152px] lg:pb-20 flex flex-col lg:flex-row gap-[60px] sm:gap-20 lg:justify-between items-center">
+                    <div className="w-full lg:w-[59%]">
+                        <div className="w-full max-w-[503px] flex flex-col gap-[22px] md:gap-8">
+                            <h1 className="text-[43px] sm:text-[64px] leading-tight font-normal text-white whitespace-pre-line">
+                                {t('pages.home.hero.title')}
+                            </h1>
+                            <p className="text-[11px] sm:text-base text-white/60 max-w-[503px] leading-[1.5]">
+                                {t('pages.home.hero.subtitle')}
+                            </p>
+                            <div className="flex flex-row flex-wrap gap-[11px] sm:gap-4">
+                                <DownloadButton image={heroAppStore} url={appleStoreUrl ?? ''} />
+                                <DownloadButton
+                                    image={heroGooglePlay}
+                                    url={downloadUrlByPlatform ?? ''}
+                                />
+                            </div>
+                        </div>
+                    </div>
+                    <img
+                        src={heroDevices}
+                        alt="hero widgets"
+                        className="w-[280px] sm:w-[80%] lg:w-[41%] max-w-[520px] h-auto object-contain object-center"
+                    />
+                </div>
+            </Wrapper>
+        </section>
+    );
+}

+ 76 - 0
src/pages/home/components/Pricing/PlanCard.tsx

@@ -0,0 +1,76 @@
+import { memo } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { PlanTagType } from '@/defines';
+import { Button } from 'antd';
+
+const PLAN_TAG_TYPE_I18N_KEY: Record<PlanTagType, string> = {
+    [PlanTagType.NONE]: '',
+    [PlanTagType.MOST_POPULAR]: 'mostPopular',
+    [PlanTagType.LIMITED_TIME]: 'limitedTime',
+};
+
+export interface PlanCardProps {
+    id: string;
+    title: string;
+    subTitle: string;
+    introduce: string;
+    tag?: string;
+    tagType?: PlanTagType;
+    isSelected?: boolean;
+    onSelected?: () => void;
+    onGetStarted?: () => void;
+}
+
+const PlanCard = memo(
+    ({
+        title,
+        subTitle,
+        introduce,
+        tag,
+        tagType,
+        isSelected = false,
+        onSelected,
+        onGetStarted,
+    }: PlanCardProps) => {
+        const { t } = useTranslation();
+        return (
+            <div
+                className={`relative box-border cursor-pointer transition-all bg-white/10 border-2 flex-col-c p-5 sm:p-8 w-full sm:w-[406px] rounded-2xl gap-2 sm:gap-3 shrink-0 ${
+                    isSelected ? 'border-[#0FA4E9]' : 'border-white/10'
+                }`}
+                onClick={onSelected}
+            >
+                {tagType != null && tagType !== PlanTagType.NONE && (
+                    <div className="absolute top-[-13.5px] bg-[#0FA4E9] px-4 start-[21.13px] rounded-full py-1">
+                        <span className="text-black text-[12px] leading-normal sm:text-[14px] font-normal uppercase">
+                            {tag?.trim()
+                                ? tag
+                                : t(`pages.pricing.planTag.${PLAN_TAG_TYPE_I18N_KEY[tagType]}`)}
+                        </span>
+                    </div>
+                )}
+                <h3 className="text-white font-normal leading-[1.5] text-center text-[20px] sm:text-[24px]">
+                    {title}
+                </h3>
+                <span className="text-white font-normal leading-[1.11] text-center text-[24px] sm:text-[28px]">
+                    {subTitle}
+                </span>
+                <span className="text-white/80 font-normal leading-[1.5] text-center text-[16px] sm:text-[20px]">
+                    {introduce}
+                </span>
+                <Button
+                    className={`bg-[#0EA5E9] text-black hover:!bg-[#0081FF] hover:!text-white active:!bg-[#0081FF]/80 active:!text-white/80 text-sm sm:text-base font-normal uppercase rounded-[25px] py-[12px] w-full h-auto border-none`}
+                    onClick={onGetStarted}
+                >
+                    {t('pages.home.pricing.selectPlan')}
+                </Button>
+            </div>
+        );
+    }
+);
+
+PlanCard.displayName = 'PlanCard';
+
+export default PlanCard;

+ 45 - 0
src/pages/home/components/Pricing/index.tsx

@@ -0,0 +1,45 @@
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+
+import PlanCard from './PlanCard';
+import { useService } from './useService';
+import Wrapper from '../Wrapper';
+
+export function Pricing() {
+    const { t } = useTranslation();
+    const navigate = useNavigate();
+    const { plans, selectedPlanId, setSelectedPlanId } = useService();
+
+    return (
+        <section className="w-full pt-7 sm:pt-16 lg:pt-[176px]">
+            <Wrapper>
+                <div className="text-center max-w-[672px] mx-auto mb-8 sm:mb-12">
+                    <h2 className="text-2xl sm:text-[32px] font-medium text-white">
+                        {t('pages.home.pricing.title')}
+                    </h2>
+                    <p className="mt-4 text-white/60 text-base text-center">
+                        {t('pages.home.pricing.subtitle')}
+                    </p>
+                </div>
+            </Wrapper>
+            <div className="w-full sm:max-w-full sm:overflow-x-scroll sm:no-scrollbar py-4">
+                <Wrapper className="w-fit min-w-full flex flex-wrap sm:flex-nowrap items-stretch gap-8">
+                    {plans.map((plan) => (
+                        <PlanCard
+                            key={plan.id}
+                            id={plan.id}
+                            title={plan.title}
+                            subTitle={plan.subTitle}
+                            introduce={plan.introduce}
+                            tag={plan.tag}
+                            tagType={plan.tagType}
+                            isSelected={selectedPlanId === plan.id}
+                            onSelected={() => setSelectedPlanId(plan.id)}
+                            onGetStarted={() => navigate(`/pricing?planId=${plan.id}`)}
+                        />
+                    ))}
+                </Wrapper>
+            </div>
+        </section>
+    );
+}

+ 63 - 0
src/pages/home/components/Pricing/useService.tsx

@@ -0,0 +1,63 @@
+import { useEffect, useState } from 'react';
+
+import { PlanTagType } from '@/defines';
+import { fetchPlanList } from '@/services/config';
+import { getUniqueSign } from '@/utils/stringUtils';
+
+export interface Plan {
+    id: string;
+    title: string;
+    subTitle: string;
+    introduce: string;
+    tag?: string;
+    tagType?: PlanTagType;
+    price: number;
+    isDefault: boolean;
+}
+
+export interface UseServiceReturn {
+    plans: Plan[];
+    selectedPlanId: string | null;
+    setSelectedPlanId: (planId: string) => void;
+}
+
+export function useService(): UseServiceReturn {
+    const [plans, setPlans] = useState<Plan[]>([]);
+    const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
+
+    useEffect(() => {
+        fetchPlanList({})
+            .then((res) => {
+                const list = res?.data?.list ?? [];
+                const mapped: Plan[] = list.map((item) => ({
+                    id: item.channelItemId ?? getUniqueSign(),
+                    title: item.title ?? '',
+                    subTitle: item.subTitle ?? '',
+                    introduce: item.introduce ?? '',
+                    tag: item.tag,
+                    tagType: item.tagType ?? PlanTagType.NONE,
+                    price: item.price ?? 0,
+                    isDefault: item.isDefault ?? false,
+                }));
+                setPlans(mapped);
+            })
+            .catch(() => {});
+    }, []);
+
+    useEffect(() => {
+        if (plans.length > 0) {
+            for (const plan of plans) {
+                if (plan.isDefault) {
+                    setSelectedPlanId(plan.id);
+                    break;
+                }
+            }
+        }
+    }, [plans]);
+
+    return {
+        plans,
+        selectedPlanId,
+        setSelectedPlanId,
+    };
+}

+ 11 - 0
src/pages/home/components/Wrapper.tsx

@@ -0,0 +1,11 @@
+import React from 'react';
+
+type WrapperProps = {
+    className?: string;
+};
+
+const Wrapper: React.FC<React.PropsWithChildren<WrapperProps>> = ({ children, className }) => {
+    return <div className={`w-full px-5 sm:px-12 lg:px-20 ${className ?? ''}`}>{children}</div>;
+};
+
+export default Wrapper;

+ 11 - 2
src/pages/home/index.tsx

@@ -1,7 +1,16 @@
+import { Download } from './components/Download';
+import { Features } from './components/Features';
+import { Hero } from './components/Hero';
+import { Pricing } from './components/Pricing';
+
+/** 设计稿基准宽度 1440px,大于时内容垂直居中;内容区最大 1280px(1440 - 80*2) */
 const Home: React.FC = () => {
     return (
-        <div>
-            <h1>Home</h1>
+        <div className="w-full max-w-[1440px] mx-auto flex flex-col justify-center max-[768px]:no-scrollbar">
+            <Hero />
+            <Features />
+            <Pricing />
+            <Download />
         </div>
     );
 };

+ 1 - 1
src/pages/pricing/components/OrderSummary/index.tsx

@@ -45,7 +45,7 @@ const OrderSummary = memo(
                     <Button
                         form={formId}
                         htmlType="submit"
-                        className={`bg-[#0EA5E9] text-white text-sm font-medium leading-[1.4] uppercase rounded-[25px] px-[42px] py-[10px] h-auto border-none hover:bg-[#0EA5E9]/80 ${!isMobile ? 'w-[300px]' : ''}`}
+                        className={`bg-[#0EA5E9] text-black text-sm font-medium leading-[1.4] uppercase rounded-[25px] px-[42px] py-[10px] h-auto border-none hover:!bg-[#0081FF] hover:!text-white active:!bg-[#0081FF]/80 active:!text-white/80 ${!isMobile ? 'w-[300px]' : ''}`}
                     >
                         {t('pages.pricing.orderSummary.goPayNow')}
                     </Button>

+ 4 - 26
src/pages/pricing/components/PlanCard/index.tsx

@@ -9,8 +9,6 @@ import checkOnIcon from '@/assets/iconify/multi-color/check-on.svg';
 import checkOffIcon from '@/assets/iconify/multi-color/check-off.svg';
 import { PlanTagType } from '@/defines';
 
-import { useActions } from './useActions';
-
 const PLAN_TAG_TYPE_I18N_KEY: Record<PlanTagType, string> = {
     [PlanTagType.NONE]: '',
     [PlanTagType.MOST_POPULAR]: 'mostPopular',
@@ -32,20 +30,12 @@ const PlanCard = memo(
     ({ title, subTitle, introduce, tag, tagType, isSelected = false, onClick }: PlanCardProps) => {
         const { t } = useTranslation();
         const { isMobile } = useResponsive();
-        const { containerRef, titleRef, subTitleRef, introduceRef, textSizes } = useActions(
-            isMobile,
-            title,
-            subTitle,
-            introduce
-        );
-
         return (
             <div
-                ref={containerRef}
                 className={`relative box-border cursor-pointer transition-all bg-white/10 border-2 ${
                     isMobile
                         ? 'flex-row-bc py-3 px-4 w-full rounded-lg'
-                        : 'flex-col-c p-[33px] w-full max-w-[362.66px] rounded-2xl gap-4'
+                        : 'flex-col-c p-[33px] w-full rounded-2xl gap-4'
                 } ${
                     isSelected
                         ? 'border-[#0FA4E9]'
@@ -81,25 +71,13 @@ const PlanCard = memo(
                     </>
                 ) : (
                     <>
-                        <h3
-                            ref={titleRef}
-                            className="text-white font-normal leading-[1.5] text-center"
-                            style={{ fontSize: `${textSizes.titleSize}px` }}
-                        >
+                        <h3 className="text-white font-normal leading-[1.5] text-center text-[28px]">
                             {title}
                         </h3>
-                        <span
-                            ref={subTitleRef}
-                            className="text-white font-normal leading-[1.11] text-center"
-                            style={{ fontSize: `${textSizes.subTitleSize}px` }}
-                        >
+                        <span className="text-white font-normal leading-[1.11] text-center text-[36px]">
                             {subTitle}
                         </span>
-                        <span
-                            ref={introduceRef}
-                            className="text-white/80 font-normal leading-[1.5] text-center"
-                            style={{ fontSize: `${textSizes.introduceSize}px` }}
-                        >
+                        <span className="text-white/80 font-normal leading-[1.5] text-center text-[22px]">
                             {introduce}
                         </span>
                     </>

+ 0 - 125
src/pages/pricing/components/PlanCard/useActions.ts

@@ -1,125 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-
-import { calculateOptimalFontSize } from '@/utils/domUtils';
-
-interface TextSizes {
-    titleSize: number;
-    subTitleSize: number;
-    introduceSize: number;
-}
-
-interface UseActionsReturn {
-    containerRef: React.RefObject<HTMLDivElement>;
-    titleRef: React.RefObject<HTMLHeadingElement>;
-    subTitleRef: React.RefObject<HTMLSpanElement>;
-    introduceRef: React.RefObject<HTMLSpanElement>;
-    textSizes: TextSizes;
-}
-
-const DEFAULT_TITLE_SIZE = 28;
-const DEFAULT_SUBTITLE_SIZE = 36;
-const DEFAULT_INTRODUCE_SIZE = 22;
-const CONTAINER_PADDING = 33 * 2; // 左右 padding 总和
-
-/**
- * PlanCard 响应逻辑 Hook
- * 处理文本自适应大小调整
- */
-export function useActions(
-    isMobile: boolean,
-    title: string,
-    subTitle: string,
-    introduce: string
-): UseActionsReturn {
-    const containerRef = useRef<HTMLDivElement>(null);
-    const titleRef = useRef<HTMLHeadingElement>(null);
-    const subTitleRef = useRef<HTMLSpanElement>(null);
-    const introduceRef = useRef<HTMLSpanElement>(null);
-
-    const [textSizes, setTextSizes] = useState<TextSizes>({
-        titleSize: DEFAULT_TITLE_SIZE,
-        subTitleSize: DEFAULT_SUBTITLE_SIZE,
-        introduceSize: DEFAULT_INTRODUCE_SIZE,
-    });
-
-    useEffect(() => {
-        if (isMobile) {
-            setTextSizes({
-                titleSize: DEFAULT_TITLE_SIZE,
-                subTitleSize: DEFAULT_SUBTITLE_SIZE,
-                introduceSize: DEFAULT_INTRODUCE_SIZE,
-            });
-            return;
-        }
-
-        const container = containerRef.current;
-        const titleEl = titleRef.current;
-        const subTitleEl = subTitleRef.current;
-        const introduceEl = introduceRef.current;
-
-        if (!container || !titleEl || !subTitleEl || !introduceEl) {
-            return;
-        }
-
-        const calculateOptimalSizes = () => {
-            const containerWidth = container.offsetWidth;
-            const availableWidth = containerWidth - CONTAINER_PADDING;
-
-            if (availableWidth <= 0) {
-                return;
-            }
-
-            const newTitleSize = calculateOptimalFontSize({
-                element: titleEl,
-                text: title,
-                defaultSize: DEFAULT_TITLE_SIZE,
-                availableWidth,
-            });
-
-            const newSubTitleSize = calculateOptimalFontSize({
-                element: subTitleEl,
-                text: subTitle,
-                defaultSize: DEFAULT_SUBTITLE_SIZE,
-                availableWidth,
-            });
-
-            const newIntroduceSize = calculateOptimalFontSize({
-                element: introduceEl,
-                text: introduce,
-                defaultSize: DEFAULT_INTRODUCE_SIZE,
-                availableWidth,
-            });
-
-            setTextSizes({
-                titleSize: newTitleSize,
-                subTitleSize: newSubTitleSize,
-                introduceSize: newIntroduceSize,
-            });
-        };
-
-        const resizeObserver = new ResizeObserver(() => {
-            requestAnimationFrame(() => {
-                calculateOptimalSizes();
-            });
-        });
-
-        resizeObserver.observe(container);
-        resizeObserver.observe(titleEl);
-        resizeObserver.observe(subTitleEl);
-        resizeObserver.observe(introduceEl);
-
-        calculateOptimalSizes();
-
-        return () => {
-            resizeObserver.disconnect();
-        };
-    }, [isMobile, title, subTitle, introduce]);
-
-    return {
-        containerRef,
-        titleRef,
-        subTitleRef,
-        introduceRef,
-        textSizes,
-    };
-}

+ 11 - 18
src/pages/pricing/index.tsx

@@ -1,4 +1,4 @@
-import { memo, useRef } from 'react';
+import { memo, useEffect } from 'react';
 
 import { Form } from 'antd';
 import { useTranslation } from 'react-i18next';
@@ -10,7 +10,6 @@ import PayMethodCard from './components/PayMethodCard';
 import PlanCard from './components/PlanCard';
 import UserInfo from './components/UserInfo';
 import { useAction } from './useAction';
-import { usePlanCardsHeightSync } from './usePlanCardsHeightSync';
 import { useService } from './useService';
 import type { Plan } from './useService';
 
@@ -20,21 +19,14 @@ export interface PlanSelectorProps {
     value?: string;
     onChange?: (planId: string) => void;
     plans: Plan[];
-    planCardsContainerRef: React.RefObject<HTMLDivElement | null>;
     isMobile: boolean;
 }
 
-function PlanSelector({
-    value,
-    onChange,
-    plans,
-    planCardsContainerRef,
-    isMobile,
-}: PlanSelectorProps) {
+function PlanSelector({ value, onChange, plans, isMobile }: PlanSelectorProps) {
     return (
         <div
-            ref={planCardsContainerRef as React.RefObject<HTMLDivElement>}
             data-count={plans.length}
+            style={{ gridAutoRows: '1fr' }}
             className={
                 isMobile
                     ? 'flex-col-c gap-5'
@@ -90,7 +82,6 @@ const Pricing = memo(() => {
     const { t } = useTranslation();
     const { isMobile } = useResponsive();
     const [form] = Form.useForm<{ planId: string; payMethod: string }>();
-    const planCardsContainerRef = useRef<HTMLDivElement>(null);
     const { plans, payMethods } = useService();
     const { handlePayNow } = useAction();
 
@@ -99,7 +90,13 @@ const Pricing = memo(() => {
     const selectedPlan = plans.find((p) => p.id === planId) ?? null;
     const selectedPayMethod = payMethod ?? null;
 
-    usePlanCardsHeightSync(planCardsContainerRef, isMobile, plans.length);
+    useEffect(() => {
+        if (plans.length === 0) return;
+        const defaultPlan = plans.find((p) => p.isDefault);
+        if (defaultPlan) {
+            form.setFieldsValue({ planId: defaultPlan.id });
+        }
+    }, [plans, form]);
 
     const onFinish = (values: { planId: string; payMethod: string }) => {
         const plan = plans.find((p) => p.id === values.planId);
@@ -142,11 +139,7 @@ const Pricing = memo(() => {
                                 ]}
                                 className="mb-0 [&_.ant-form-item-explain]:mt-3"
                             >
-                                <PlanSelector
-                                    plans={plans}
-                                    planCardsContainerRef={planCardsContainerRef}
-                                    isMobile={isMobile}
-                                />
+                                <PlanSelector plans={plans} isMobile={isMobile} />
                             </Form.Item>
                         </div>
                         <div className="flex flex-col gap-4 mt-1">

+ 0 - 45
src/pages/pricing/usePlanCardsHeightSync.ts

@@ -1,45 +0,0 @@
-import { useEffect, RefObject } from 'react';
-
-function syncPlanCardsHeight(container: HTMLDivElement | null) {
-    if (!container) return;
-    const children = Array.from(container.children) as HTMLElement[];
-    if (children.length === 0) return;
-    const maxHeight = Math.max(...children.map((el) => el.offsetHeight));
-    children.forEach((el) => {
-        el.style.minHeight = `${maxHeight}px`;
-    });
-}
-
-export function usePlanCardsHeightSync(
-    containerRef: RefObject<HTMLDivElement | null>,
-    isMobile: boolean,
-    plansLength: number
-) {
-    useEffect(() => {
-        const container = containerRef.current;
-        const cancelled = { current: false };
-        const clearMinHeight = () => {
-            Array.from(container?.children ?? []).forEach((el) => {
-                (el as HTMLElement).style.minHeight = '';
-            });
-        };
-        if (isMobile) {
-            clearMinHeight();
-            return () => clearMinHeight();
-        }
-        const run = () => {
-            requestAnimationFrame(() => {
-                if (cancelled.current) return;
-                syncPlanCardsHeight(container);
-            });
-        };
-        run();
-        const observer = new ResizeObserver(run);
-        if (container) observer.observe(container);
-        return () => {
-            cancelled.current = true;
-            clearMinHeight();
-            observer.disconnect();
-        };
-    }, [isMobile, plansLength]);
-}

+ 2 - 0
src/pages/pricing/useService.ts

@@ -16,6 +16,7 @@ export interface Plan {
     tag?: string;
     tagType?: PlanTagType;
     price: number;
+    isDefault: boolean;
 }
 
 export interface UseServiceReturn {
@@ -57,6 +58,7 @@ export function useService(): UseServiceReturn {
                     tag: item.tag,
                     tagType: item.tagType ?? PlanTagType.NONE,
                     price: item.price ?? 0,
+                    isDefault: item.isDefault ?? false,
                 }));
                 setPlans(mapped);
             })