Explorar el Código

feat: 购买页面

BaiLuoYan hace 1 mes
padre
commit
1bd4fe5c4f

+ 1 - 1
src/components/Dialog/index.tsx

@@ -40,7 +40,7 @@ const Dialog = memo(({ open, id, icon, title, content, buttons, zIndex, maskClos
     return (
         <Fragment>
             <div
-                className="fixed inset-0 bg-black/50 flex items-center justify-center p-4"
+                className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center p-4"
                 style={{ zIndex }}
                 onClick={handleOverlayClick}
             >

+ 21 - 6
src/components/Topbar/index.tsx

@@ -16,16 +16,19 @@ import { useService } from './useService';
 const Topbar = memo(() => {
     const { t } = useTranslation();
     const { isMobile } = useResponsive();
+    const isRtl = t('DIR') === 'rtl';
     const { menuItems, isActive, getMenuItemLabel } = useService();
     const {
         menuContainerRef,
         isMobileMenuOpen,
+        isMobileMenuClosing,
         isOverflowMenuOpen,
         visibleMenuItems,
         overflowMenuItems,
         handleMenuClick,
         toggleMobileMenu,
         closeMobileMenu,
+        handleMenuAnimationEnd,
         toggleOverflowMenu,
         setMenuItemRef,
     } = useAction({ menuItems, isMobile });
@@ -109,8 +112,9 @@ const Topbar = memo(() => {
                     {/* Mobile Menu Button */}
                     {isMobile && (
                         <button
+                            type="button"
                             onClick={toggleMobileMenu}
-                            className="p-2 rounded-lg bg-transparent border-none text-white"
+                            className="p-2 rounded-lg bg-transparent border-none text-white outline-none focus:outline-none focus:bg-transparent active:bg-transparent hover:text-[#0EA5E9]/80 active:text-[#0EA5E9]/60 [-webkit-tap-highlight-color:transparent] transition-colors"
                             aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
                         >
                             <Icon
@@ -123,15 +127,26 @@ const Topbar = memo(() => {
             </header>
 
             {/* Mobile Expanded Menu (Sidebar) - 移到 header 外部 */}
-            {isMobile && isMobileMenuOpen && (
+            {isMobile && (isMobileMenuOpen || isMobileMenuClosing) && (
                 <>
-                    {/* 遮罩层 */}
+                    {/* 遮罩层(毛玻璃) */}
                     <div
-                        className="fixed inset-0 bg-black/50 z-40 top-[81px]"
+                        className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 top-[81px]"
                         onClick={closeMobileMenu}
                     />
-                    {/* 侧边栏菜单 */}
-                    <nav className="fixed end-0 top-[81px] bottom-0 w-[250px] bg-black/80 backdrop-blur-[4px] z-50">
+                    {/* 侧边栏菜单:LTR 自右向左进入/向右退出,RTL 自左向右进入/向左退出 */}
+                    <nav
+                        className={`fixed end-0 top-[81px] bottom-0 w-[250px] bg-black/80 backdrop-blur-[4px] z-50 ${
+                            isMobileMenuClosing
+                                ? isRtl
+                                    ? 'animate-slide-out-to-start'
+                                    : 'animate-slide-out-to-end'
+                                : isRtl
+                                  ? 'animate-slide-in-from-start'
+                                  : 'animate-slide-in-from-end'
+                        }`}
+                        onAnimationEnd={handleMenuAnimationEnd}
+                    >
                         <div className="h-full px-[30px] pt-[30px] flex flex-col gap-[14px]">
                             {menuItems.map((item: NavMenuItem) => {
                                 const active = isActive(item.path);

+ 18 - 3
src/components/Topbar/useAction.ts

@@ -21,6 +21,7 @@ export function useAction({ menuItems, isMobile }: UseActionParams) {
     const menuContainerRef = useRef<HTMLDivElement>(null);
     const menuItemsRef = useRef<Map<string, HTMLButtonElement>>(new Map());
     const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+    const [isMobileMenuClosing, setIsMobileMenuClosing] = useState(false);
     const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
     const [visibleMenuItems, setVisibleMenuItems] = useState<NavMenuItem[]>([]);
     const [overflowMenuItems, setOverflowMenuItems] = useState<NavMenuItem[]>([]);
@@ -99,13 +100,25 @@ export function useAction({ menuItems, isMobile }: UseActionParams) {
     );
 
     const toggleMobileMenu = useCallback(() => {
-        setIsMobileMenuOpen((prev) => !prev);
-    }, []);
+        if (isMobileMenuOpen) {
+            setIsMobileMenuClosing(true);
+        } else {
+            setIsMobileMenuOpen(true);
+            setIsMobileMenuClosing(false);
+        }
+    }, [isMobileMenuOpen]);
 
     const closeMobileMenu = useCallback(() => {
-        setIsMobileMenuOpen(false);
+        setIsMobileMenuClosing(true);
     }, []);
 
+    const handleMenuAnimationEnd = useCallback(() => {
+        if (isMobileMenuClosing) {
+            setIsMobileMenuOpen(false);
+            setIsMobileMenuClosing(false);
+        }
+    }, [isMobileMenuClosing]);
+
     const toggleOverflowMenu = useCallback(() => {
         setIsOverflowMenuOpen((prev) => !prev);
     }, []);
@@ -124,12 +137,14 @@ export function useAction({ menuItems, isMobile }: UseActionParams) {
         menuContainerRef,
         menuItemsRef,
         isMobileMenuOpen,
+        isMobileMenuClosing,
         isOverflowMenuOpen,
         visibleMenuItems,
         overflowMenuItems,
         handleMenuClick,
         toggleMobileMenu,
         closeMobileMenu,
+        handleMenuAnimationEnd,
         toggleOverflowMenu,
         setMenuItemRef,
     };

+ 2 - 1
src/locales/en-US/pages.ts

@@ -20,7 +20,8 @@ export default {
     // 以下是 Pricing 页面的翻译
     pricing: {
         title: 'Purchase NOMO VPN Plan',
-        subTitle: 'Choose a plan that suits you',
+        selecPlan: 'Select a plan that suits you',
+        selectPayMethod: 'Select your payment method',
         orderSummary: {
             account: 'Your Account',
             paymentMethod: 'Payment Method',

+ 2 - 1
src/locales/fa-IR/pages.ts

@@ -20,7 +20,8 @@ export default {
     // 以下是 Pricing 页面的翻译
     pricing: {
         title: 'خرید پلن NOMO VPN',
-        subTitle: 'یک پلن را انتخاب کنید که به شما مناسب است',
+        selecPlan: 'یک پلن را انتخاب کنید که به شما مناسب است',
+        selectPayMethod: 'روش پرداخت خود را انتخاب کنید',
         orderSummary: {
             account: 'حساب شما',
             paymentMethod: 'روش پرداخت',

+ 2 - 1
src/locales/zh-CN/pages.ts

@@ -20,7 +20,8 @@ export default {
     // 以下是 Pricing 页面的翻译
     pricing: {
         title: '购买 NOMO VPN 套餐',
-        subTitle: '选择一个适合您的套餐',
+        selecPlan: '选择一个适合您的套餐',
+        selectPayMethod: '选择您的支付方式',
         orderSummary: {
             account: '您的账户',
             paymentMethod: '支付方式',

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

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
 import { useResponsive } from '@/hooks/useResponsive';
 
 import LabelValueItem from '../LabelValueItem';
-import type { Plan } from '../PricingContent/useService';
+import type { Plan } from '../../useService';
 import { useService } from './useService';
 
 export interface OrderSummaryProps {

+ 1 - 1
src/pages/pricing/components/OrderSummary/useService.ts

@@ -3,7 +3,7 @@ import { useMemo } from 'react';
 import { createLocalTools } from '@/utils/localUtils';
 import { userKey } from '@/utils/authUtils';
 
-import type { Plan } from '../PricingContent/useService';
+import type { Plan } from '../../useService';
 
 const ls = createLocalTools();
 

+ 0 - 66
src/pages/pricing/components/PricingContent/index.tsx

@@ -1,66 +0,0 @@
-import { memo } from 'react';
-
-import { useTranslation } from 'react-i18next';
-
-import { useResponsive } from '@/hooks/useResponsive';
-
-import PlanCard from '../PlanCard';
-import OrderSummary from '../OrderSummary';
-import { useAction } from './useAction';
-import { useService } from './useService';
-import UserInfo from '../UserInfo';
-
-const PricingContent = memo(() => {
-    const { t } = useTranslation();
-    const { isMobile } = useResponsive();
-    const { selectedPlanId, handlePlanClick } = useAction();
-    const { plans } = useService();
-
-    const selectedPlan = plans.find((plan) => plan.id === selectedPlanId) || null;
-
-    return (
-        <div className="max-w-[1440px] w-full px-[30px]">
-            <div className={`bg-[#0F1116] px-5 sm:px-6 lg:px-[100px] py-[30px] pb-[100px] my-[50px] rounded-[12px] flex flex-col gap-10 ${isMobile ? 'gap-5' : 'gap-10'}`}>
-                <span
-                    className={`text-white font-semibold leading-[1.43] text-center uppercase ${isMobile ? 'text-[22px]' : 'text-[35px]'}`}
-                >
-                    {t('pages.pricing.title')}
-                </span>
-                <UserInfo />
-                <span
-                    className={`text-white font-semibold leading-[1.43] uppercase ${isMobile ? 'text-[16px]' : 'text-[22px]'}`}
-                >
-                    {t('pages.pricing.subTitle')}
-                </span>
-                <div className="flex flex-col gap-10">
-                    <div
-                        className={`flex ${
-                            isMobile
-                                ? 'flex-col items-center'
-                                : 'flex-row items-center justify-center'
-                        } 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}
-                                onClick={() => handlePlanClick(plan.id)}
-                            />
-                        ))}
-                    </div>
-                </div>
-                <OrderSummary selectedPlan={selectedPlan} />
-            </div>
-        </div>
-    );
-});
-
-PricingContent.displayName = 'PricingContent';
-
-export default PricingContent;

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

@@ -13,7 +13,7 @@ const UserInfo = memo(() => {
     const { userAccount, planExpireDate } = useService();
 
     return (
-        <div className={`${isMobile ? 'flex flex-col gap-2.5' : 'flex-row-bc'}`}>
+        <div className={`${isMobile ? 'flex flex-col gap-2' : 'flex-row-bc'}`}>
             <LabelValueItem label={t('pages.pricing.userInfo.account')} value={userAccount} />
             <LabelValueItem
                 label={t('pages.pricing.userInfo.planExpireDate')}

+ 61 - 3
src/pages/pricing/index.tsx

@@ -1,11 +1,69 @@
 import { memo } from 'react';
-
-import PricingContent from './components/PricingContent';
+import UserInfo from './components/UserInfo';
+import PlanCard from './components/PlanCard';
+import OrderSummary from './components/OrderSummary';
+import { useTranslation } from 'react-i18next';
+import { useResponsive } from '@/hooks/useResponsive';
+import { useAction } from './useAction';
+import { useService } from './useService';
 
 const Pricing = memo(() => {
+    const { t } = useTranslation();
+    const { isMobile } = useResponsive();
+    const { selectedPlanId, handlePlanClick } = useAction();
+    const { plans } = useService();
+
+    const selectedPlan = plans.find((plan) => plan.id === selectedPlanId) || null;
+
     return (
         <div className="flex items-start justify-center">
-            <PricingContent />
+            <div className={`max-w-[1440px] w-full ${isMobile ? 'px-0' : 'px-[30px]'}`}>
+                <div
+                    className={`bg-[#0F1116] px-5 sm:px-5 lg:px-[100px] py-[30px] pb-[100px] flex flex-col ${isMobile ? 'gap-5 mt-0' : 'gap-10 my-[50px] rounded-[12px]'}`}
+                >
+                    <span
+                        className={`text-white font-semibold leading-[1.43] text-center uppercase ${isMobile ? 'text-[22px]' : 'text-[35px]'}`}
+                    >
+                        {t('pages.pricing.title')}
+                    </span>
+                    <UserInfo />
+                    <div className="flex flex-col gap-5">
+                        <span
+                            className={`text-white font-semibold leading-[1.43] ${isMobile ? 'text-[16px]' : 'text-[22px]'}`}
+                        >
+                            {t('pages.pricing.selecPlan')}
+                        </span>
+                        <div
+                            className={`flex ${
+                                isMobile
+                                    ? 'flex-col items-center gap-5'
+                                    : 'flex-row items-center justify-center 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}
+                                    onClick={() => handlePlanClick(plan.id)}
+                                />
+                            ))}
+                        </div>
+                    </div>
+                    <span
+                        className={`text-white font-semibold leading-[1.43] ${isMobile ? 'text-[16px]' : 'text-[22px]'}`}
+                    >
+                        {t('pages.pricing.selectPayMethod')}
+                    </span>
+                    <div className="flex items-center justify-start"></div>
+                    <OrderSummary selectedPlan={selectedPlan} />
+                </div>
+            </div>
         </div>
     );
 });

+ 0 - 0
src/pages/pricing/components/PricingContent/useAction.ts → src/pages/pricing/useAction.ts


+ 0 - 0
src/pages/pricing/components/PricingContent/useService.ts → src/pages/pricing/useService.ts


+ 1 - 0
src/router/routes.tsx

@@ -44,6 +44,7 @@ const routes: AppRouteObject[] = [
             {
                 name: 'test',
                 path: '/test',
+                hideInMenu: true,
                 children: [
                     {
                         name: 'test1',

+ 26 - 1
tailwind.config.js

@@ -5,7 +5,32 @@
 export default {
     content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
     theme: {
-        extend: {},
+        extend: {
+            keyframes: {
+                'slide-in-from-end': {
+                    from: { transform: 'translateX(100%)' },
+                    to: { transform: 'translateX(0)' },
+                },
+                'slide-in-from-start': {
+                    from: { transform: 'translateX(-100%)' },
+                    to: { transform: 'translateX(0)' },
+                },
+                'slide-out-to-end': {
+                    from: { transform: 'translateX(0)' },
+                    to: { transform: 'translateX(100%)' },
+                },
+                'slide-out-to-start': {
+                    from: { transform: 'translateX(0)' },
+                    to: { transform: 'translateX(-100%)' },
+                },
+            },
+            animation: {
+                'slide-in-from-end': 'slide-in-from-end 0.25s ease-out forwards',
+                'slide-in-from-start': 'slide-in-from-start 0.25s ease-out forwards',
+                'slide-out-to-end': 'slide-out-to-end 0.25s ease-out forwards',
+                'slide-out-to-start': 'slide-out-to-start 0.25s ease-out forwards',
+            },
+        },
     },
     // plugins: [rtl, flip],
     corePlugins: {