Parcourir la source

feat: 购买套餐时,若未登录,提示用户登录

BaiLuoYan il y a 1 mois
Parent
commit
3644f68d29

+ 10 - 0
.env

@@ -25,3 +25,13 @@ VITE_ENABLE_FIREBASE="false"
 
 # API 配置
 VITE_API_BASE_URL="https://ow.clickto.dev/api/v1"
+
+# Google Play 商店地址
+VITE_APP_GOOGLE_STORE_URL="https://www.baidu.com"
+# Apple App Store 地址
+VITE_APP_APPLE_STORE_URL="https://www.baidu.com"
+# APK 下载地址
+VITE_APP_APK_URL="https://www.baidu.com"
+
+# deeplink 链接地址
+VITE_APP_DEEPLINK_URL="deepLink://"

+ 2 - 2
src/App.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-import { ConfigProvider } from 'antd';
+import { ConfigProvider, theme } from 'antd';
 import enUS from 'antd/locale/en_US';
 import faIR from 'antd/locale/fa_IR';
 import zhCN from 'antd/locale/zh_CN';
@@ -29,7 +29,7 @@ const App: React.FC = () => {
     };
 
     return (
-        <ConfigProvider locale={locale}>
+        <ConfigProvider locale={locale} theme={{ algorithm: theme.darkAlgorithm }}>
             <ModelProviders>
                 <RouterProvider router={router} />
                 <DialogContainer />

+ 79 - 0
src/components/LoginForm/index.tsx

@@ -0,0 +1,79 @@
+import { memo } from 'react';
+
+import { Button, Form, Input } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import { useResponsive } from '@/hooks/useSize';
+import { userConfigModel } from '@/models/userConfigModel';
+import { setToken } from '@/utils/authUtils';
+
+import { useAction } from './useAction';
+
+export interface LoginFormProps {
+    onSuccess?: () => void;
+}
+
+const LoginForm = memo(({ onSuccess }: LoginFormProps) => {
+    const { t } = useTranslation();
+    const { isMobile } = useResponsive();
+    const { setUserConfig } = userConfigModel.useModel();
+    const [form] = Form.useForm();
+    const { loading, errorMessage, errorKey, handleSubmit } = useAction({
+        setToken,
+        setUserConfig,
+        onSuccess,
+    });
+
+    return (
+        <Form
+            form={form}
+            layout="horizontal"
+            labelCol={{ flex: '0 0 80px' }}
+            className="flex flex-col"
+            onFinish={(values) => handleSubmit(values.username, values.password)}
+            autoComplete="on"
+        >
+            <Form.Item
+                name="username"
+                label={isMobile ? undefined : t('pages.pricing.payFlow.username')}
+                rules={[{ required: true, message: t('pages.pricing.payFlow.loginRequired') }]}
+            >
+                <Input
+                    placeholder={t('pages.pricing.payFlow.username')}
+                    autoComplete="username"
+                    className="bg-white/10 border-white/20 text-white"
+                />
+            </Form.Item>
+            <Form.Item
+                name="password"
+                label={isMobile ? undefined : t('pages.pricing.payFlow.password')}
+                rules={[{ required: true, message: t('pages.pricing.payFlow.loginRequired') }]}
+            >
+                <Input.Password
+                    placeholder={t('pages.pricing.payFlow.password')}
+                    autoComplete="current-password"
+                    className="bg-white/10 border-white/20 text-white"
+                />
+            </Form.Item>
+            {(errorMessage || errorKey) && (
+                <p className="text-red-400 text-sm">
+                    {errorMessage ?? t(`pages.pricing.payFlow.${errorKey}`)}
+                </p>
+            )}
+            <Form.Item className="mt-5 mb-0">
+                <Button
+                    type="primary"
+                    htmlType="submit"
+                    loading={loading}
+                    className="w-full bg-[#0EA5E9] border-none"
+                >
+                    {t('pages.pricing.payFlow.loginSubmit')}
+                </Button>
+            </Form.Item>
+        </Form>
+    );
+});
+
+LoginForm.displayName = 'LoginForm';
+
+export default LoginForm;

+ 59 - 0
src/components/LoginForm/useAction.ts

@@ -0,0 +1,59 @@
+import { useCallback, useState } from 'react';
+
+import { fetchLogin } from '@/services/login';
+
+export interface UseActionParams {
+    setToken: (data: API.UserInfo) => void;
+    setUserConfig: (data: API.UserInfo | null) => void;
+    onSuccess?: () => void;
+}
+
+export type LoginErrorKey = 'loginError' | null;
+
+function getErrorMessage(err: unknown): { message: string | null; errorKey: LoginErrorKey } {
+    const anyErr = err as { name?: string; info?: { errorMessage?: string }; response?: { data?: { errorMessage?: string } } };
+    const backendMsg =
+        anyErr?.info?.errorMessage ?? anyErr?.response?.data?.errorMessage ?? null;
+    if (backendMsg && typeof backendMsg === 'string') {
+        return { message: backendMsg, errorKey: null };
+    }
+    return { message: null, errorKey: 'loginError' };
+}
+
+export function useAction({ setToken, setUserConfig, onSuccess }: UseActionParams) {
+    const [loading, setLoading] = useState(false);
+    const [errorMessage, setErrorMessage] = useState<string | null>(null);
+    const [errorKey, setErrorKey] = useState<LoginErrorKey>(null);
+
+    const handleSubmit = useCallback(
+        (username: string, password: string) => {
+            setErrorMessage(null);
+            setErrorKey(null);
+            setLoading(true);
+            fetchLogin({ username: username.trim(), password }, { skipErrorHandler: true })
+                .then((res) => {
+                    const data = res?.data;
+                    if (!data) return;
+                    setToken(data);
+                    setUserConfig(data);
+                    onSuccess?.();
+                })
+                .catch((err) => {
+                    const { message: msg, errorKey: key } = getErrorMessage(err);
+                    setErrorMessage(msg);
+                    setErrorKey(key);
+                })
+                .finally(() => {
+                    setLoading(false);
+                });
+        },
+        [setToken, setUserConfig, onSuccess]
+    );
+
+    return {
+        loading,
+        errorMessage,
+        errorKey,
+        handleSubmit,
+    };
+}

+ 4 - 0
src/config/request/encryptionInterceptors.ts

@@ -51,6 +51,10 @@ export const requestEncryptionInterceptor: IRequestInterceptorAxios = async (con
     }
 
     config.data = config.data ?? {}; // 如果请求体为空,则设置为空对象(后端需要非空请求体)
+    console.log(
+        `⬆️ 🔐 [requestEncryptionInterceptor] api (${config.url}) request data:`,
+        config.data
+    );
     if (['post', 'put'].includes(config.method?.toLowerCase() || '')) {
         // 设置 Content-Type 为 application/octet-stream
         config.headers['Content-Type'] = 'application/octet-stream';

+ 106 - 0
src/hooks/useAppUrls.ts

@@ -0,0 +1,106 @@
+import { useMemo } from 'react';
+
+import { useSearchParams } from 'react-router-dom';
+
+const env = typeof import.meta !== 'undefined' ? (import.meta as any).env : undefined;
+
+const GOOGLE_STORE_URL = env?.VITE_APP_GOOGLE_STORE_URL ?? '';
+console.log('🚀 ~ useAppUrls.ts:8 ~ GOOGLE_STORE_URL:', GOOGLE_STORE_URL);
+const APPLE_STORE_URL = env?.VITE_APP_APPLE_STORE_URL ?? '';
+console.log('🚀 ~ useAppUrls.ts:10 ~ APPLE_STORE_URL:', APPLE_STORE_URL);
+const APK_URL = env?.VITE_APP_APK_URL ?? '';
+console.log('🚀 ~ useAppUrls.ts:12 ~ APK_URL:', APK_URL);
+
+const DEEPLINK_URL = env?.VITE_APP_DEEPLINK_URL ?? '';
+console.log('🚀 ~ useAppUrls.ts:15 ~ DEEPLINK_URL:', DEEPLINK_URL);
+
+const REFERRER_PARAM = 'referrer';
+
+function getDownloadUrlByPlatform(
+    google: string,
+    apple: string,
+    apk: string,
+    fallback: string
+): string {
+    if (typeof navigator === 'undefined' || !navigator.userAgent)
+        return fallback || apk || google || apple;
+    const ua = navigator.userAgent;
+    const isIos = /iPad|iPhone|iPod/.test(ua);
+    const isAndroid = /Android/.test(ua);
+    if (isIos && apple) return apple;
+    if (isAndroid && google) return google;
+    if (isAndroid && apk) return apk;
+    return fallback || apk || google || apple;
+}
+
+export interface AppUrls {
+    /** 前往 App 注册的 deeplink(含 URL 中的 referrer 参数),空则仅展示文案不跳转 */
+    deeplinkUrl: string;
+    /** Google Play 商店地址,未配置时为空 */
+    googleStoreUrl: string;
+    /** Apple App Store 地址,未配置时为空 */
+    appleStoreUrl: string;
+    /** APK 直链地址,未配置时为空 */
+    apkUrl: string;
+    /** 根据当前用户平台自动选择的下载地址:iOS→Apple 商店,Android→Google 商店或 APK,其他→fallback/APK/Google/Apple */
+    downloadUrlByPlatform: string;
+}
+
+/**
+ * 统一获取 App 相关链接:注册 deeplink、下载地址等,均从环境变量与当前 URL 拼出。
+ * 后续新增链接时在 AppUrls 与下方实现中扩展即可。
+ */
+export function useAppUrls(): AppUrls {
+    const [searchParams] = useSearchParams();
+
+    return useMemo<AppUrls>(() => {
+        const referrer = searchParams.get(REFERRER_PARAM) || '';
+        const referrerEnc = referrer.trim() ? encodeURIComponent(referrer.trim()) : '';
+
+        const deeplinkUrl = (() => {
+            if (!DEEPLINK_URL) return '';
+            if (!referrerEnc) return DEEPLINK_URL;
+            try {
+                const sep = DEEPLINK_URL.includes('?') ? '&' : '?';
+                return `${DEEPLINK_URL}${sep}${REFERRER_PARAM}=${referrerEnc}`;
+            } catch {
+                return '';
+            }
+        })();
+
+        const googleStoreUrl = (() => {
+            if (!GOOGLE_STORE_URL) return '';
+            if (!referrerEnc) return GOOGLE_STORE_URL;
+            try {
+                const sep = GOOGLE_STORE_URL.includes('?') ? '&' : '?';
+                return `${GOOGLE_STORE_URL}${sep}${REFERRER_PARAM}=${referrerEnc}`;
+            } catch {
+                return GOOGLE_STORE_URL;
+            }
+        })();
+
+        const apkUrl = (() => {
+            if (!APK_URL) return '';
+            if (!referrerEnc) return APK_URL;
+            try {
+                const sep = APK_URL.includes('?') ? '&' : '?';
+                return `${APK_URL}${sep}${REFERRER_PARAM}=${referrerEnc}`;
+            } catch {
+                return APK_URL;
+            }
+        })();
+
+        return {
+            deeplinkUrl,
+            googleStoreUrl,
+            appleStoreUrl: APPLE_STORE_URL,
+            apkUrl,
+            downloadUrlByPlatform: getDownloadUrlByPlatform(
+                googleStoreUrl,
+                APPLE_STORE_URL,
+                apkUrl,
+                ''
+            ),
+        };
+    }, [searchParams]);
+}

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

@@ -41,6 +41,23 @@ export default {
             terms: 'Continue to pay, you agree to our service terms',
             goPayNow: 'Pay Now',
         },
+        payFlow: {
+            loginTitle: 'Please log in',
+            loginPrompt: 'Log in to continue payment. No account? <linkText>Go to app</linkText> to register, or <downloadLink>download app</downloadLink>',
+            username: 'Username',
+            password: 'Password',
+            loginSubmit: 'Log in',
+            loginRequired: 'Please enter username and password',
+            loginError: 'Login failed',
+            goToPayTitle: 'Go to payment',
+            goToPayDesc: 'Click the button below to go to the payment page',
+            goToPayButton: 'Go to pay',
+            waitingTitle: 'Waiting for payment',
+            waitingDesc: 'Please complete payment on the payment page. This dialog will close automatically when done.',
+            closeWaiting: 'Close',
+            paySuccess: 'Payment successful',
+            payFailed: 'Payment failed',
+        },
     },
 
     privacyPolicy: {

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

@@ -41,6 +41,23 @@ export default {
             terms: 'با ادامه پرداخت، شما به طور پیش‌فرض با شرایط خدمات ما موافقت می‌کنید.',
             goPayNow: 'پرداخت کن',
         },
+        payFlow: {
+            loginTitle: 'لطفاً وارد شوید',
+            loginPrompt: 'برای ادامه پرداخت وارد شوید. حساب ندارید؟ <linkText>به اپ بروید</linkText> برای ثبت‌نام، یا <downloadLink>دانلود اپ</downloadLink>',
+            username: 'نام کاربری',
+            password: 'رمز عبور',
+            loginSubmit: 'ورود',
+            loginRequired: 'لطفاً نام کاربری و رمز عبور را وارد کنید',
+            loginError: 'ورود ناموفق',
+            goToPayTitle: 'رفتن به پرداخت',
+            goToPayDesc: 'برای رفتن به صفحه پرداخت دکمه زیر را بزنید',
+            goToPayButton: 'پرداخت',
+            waitingTitle: 'در انتظار پرداخت',
+            waitingDesc: 'لطفاً در صفحه پرداخت عملیات را تکمیل کنید. پس از اتمام این پنجره بسته می‌شود.',
+            closeWaiting: 'بستن',
+            paySuccess: 'پرداخت موفق',
+            payFailed: 'پرداخت ناموفق',
+        },
     },
 
     privacyPolicy: {

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

@@ -41,6 +41,23 @@ export default {
             terms: '继续支付,您同意我们的服务条款',
             goPayNow: '立即支付',
         },
+        payFlow: {
+            loginTitle: '请先登录',
+            loginPrompt: '登录后即可继续支付。没有账号?<linkText>前往 APP</linkText> 注册,或<downloadLink>下载 APP </downloadLink>',
+            username: '用户名',
+            password: '密码',
+            loginSubmit: '登录',
+            loginRequired: '请输入用户名和密码',
+            loginError: '登录失败',
+            goToPayTitle: '跳转支付',
+            goToPayDesc: '点击下方按钮跳转到支付页面完成支付',
+            goToPayButton: '去支付',
+            waitingTitle: '等待支付',
+            waitingDesc: '请在支付页面完成支付,支付完成后将自动关闭',
+            closeWaiting: '关闭',
+            paySuccess: '支付成功',
+            payFailed: '支付失败',
+        },
     },
 
     privacyPolicy: {

+ 50 - 0
src/pages/pricing/components/OrderSummary/PayWaitingContent.tsx

@@ -0,0 +1,50 @@
+import { useEffect, useRef } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { PayOrderStatus } from '@/defines';
+import { fetchPayOrderStatus } from '@/services/config';
+
+const POLL_INTERVAL_MS = 2500;
+
+export interface PayWaitingContentProps {
+    orderId: string;
+    onClose: () => void;
+}
+
+export function PayWaitingContent({ orderId, onClose }: PayWaitingContentProps) {
+    const { t } = useTranslation();
+    const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+
+    useEffect(() => {
+        const poll = () => {
+            fetchPayOrderStatus({ orderId })
+                .then((res) => {
+                    const state = res?.data?.orderState;
+                    if (state === PayOrderStatus.PAID || state === PayOrderStatus.FAILED) {
+                        if (timerRef.current) {
+                            clearInterval(timerRef.current);
+                            timerRef.current = null;
+                        }
+                        onClose();
+                    }
+                })
+                .catch(() => {});
+        };
+
+        poll();
+        timerRef.current = setInterval(poll, POLL_INTERVAL_MS);
+
+        return () => {
+            if (timerRef.current) {
+                clearInterval(timerRef.current);
+            }
+        };
+    }, [orderId, onClose]);
+
+    return (
+        <p className="text-white/90 text-sm leading-[1.43]">
+            {t('pages.pricing.payFlow.waitingDesc')}
+        </p>
+    );
+}

+ 0 - 23
src/pages/pricing/components/OrderSummary/useAction.ts

@@ -1,23 +0,0 @@
-import { useCallback } from 'react';
-
-import type { Plan } from '../../useService';
-
-export interface UseActionReturn {
-    handlePayNow: () => void;
-}
-
-export interface UseActionParams {
-    selectedPlan: Plan | null;
-    selectedPayMethod: string | null;
-}
-
-export function useAction({ selectedPlan, selectedPayMethod }: UseActionParams): UseActionReturn {
-    const handlePayNow = useCallback(() => {
-        //TODO: 响应支付按钮点击事件
-        console.log('handlePayNow', selectedPlan, selectedPayMethod);
-    }, [selectedPlan, selectedPayMethod]);
-
-    return {
-        handlePayNow,
-    };
-}

+ 167 - 0
src/pages/pricing/components/OrderSummary/useAction.tsx

@@ -0,0 +1,167 @@
+import { useCallback } from 'react';
+
+import { Trans, useTranslation } from 'react-i18next';
+
+import LoginForm from '@/components/LoginForm';
+import { useAppUrls } from '@/hooks/useAppUrls';
+import { dialogModel } from '@/models/dialogModel';
+import { fetchPayOrderCreate } from '@/services/config';
+import { getToken } from '@/utils/authUtils';
+import { currentUnixTimestamp } from '@/utils/timeUtils';
+
+import { PayWaitingContent } from './PayWaitingContent';
+import type { Plan } from '../../useService';
+
+const ORDER_TYPE_PLAN = 1;
+
+export interface UseActionReturn {
+    handlePayNow: () => void;
+}
+
+export interface UseActionParams {
+    selectedPlan: Plan | null;
+    selectedPayMethod: string | null;
+}
+
+export function useAction({ selectedPlan, selectedPayMethod }: UseActionParams): UseActionReturn {
+    const { t } = useTranslation();
+    const { openDialog, closeDialog } = dialogModel.useModel();
+    const { deeplinkUrl, downloadUrlByPlatform } = useAppUrls();
+
+
+    const handlePayNow = useCallback(() => {
+        if (!selectedPlan || !selectedPayMethod) return;
+        console.log('🚀 ~ useAction.tsx:30 ~ useAction ~ downloadUrlByPlatform:', downloadUrlByPlatform);
+        console.log('🚀 ~ useAction.tsx:30 ~ useAction ~ deeplinkUrl:', deeplinkUrl);
+        const token = getToken();
+        const expired =
+            !token?.accessExpires || (token.accessExpires ?? 0) - currentUnixTimestamp() <= 0;
+        if (!token?.accessToken || expired) {
+            const id = openDialog({
+                title: t('pages.pricing.payFlow.loginTitle'),
+                content: (
+                    <div className="flex flex-col gap-2">
+                        <p className="text-white/80 text-sm mb-2">
+                            <Trans
+                                i18nKey="pages.pricing.payFlow.loginPrompt"
+                                components={{
+                                    linkText: (() => {
+                                        const Wrap = ({
+                                            children,
+                                        }: {
+                                            children?: React.ReactNode;
+                                        }) =>
+                                            deeplinkUrl ? (
+                                                <a
+                                                    href={deeplinkUrl}
+                                                    className="text-[#0EA5E9] hover:underline"
+                                                    target="_blank"
+                                                    rel="noopener noreferrer"
+                                                >
+                                                    {children}
+                                                </a>
+                                            ) : (
+                                                <span>{children}</span>
+                                            );
+                                        return <Wrap />;
+                                    })(),
+                                    downloadLink: (() => {
+                                        const Wrap = ({
+                                            children,
+                                        }: {
+                                            children?: React.ReactNode;
+                                        }) =>
+                                            downloadUrlByPlatform ? (
+                                                <a
+                                                    href={downloadUrlByPlatform}
+                                                    className="text-[#0EA5E9] hover:underline"
+                                                    target="_blank"
+                                                    rel="noopener noreferrer"
+                                                >
+                                                    {children}
+                                                </a>
+                                            ) : (
+                                                <span>{children}</span>
+                                            );
+                                        return <Wrap />;
+                                    })(),
+                                }}
+                            />
+                        </p>
+                        <LoginForm onSuccess={() => closeDialog(id)} />
+                    </div>
+                ),
+                maskClosable: true,
+            });
+            return;
+        }
+
+        fetchPayOrderCreate({
+            orderType: ORDER_TYPE_PLAN,
+            payType: selectedPayMethod,
+            channelItemId: selectedPlan.id,
+        })
+            .then((res) => {
+                const order = res?.data?.userPayOrder;
+                if (!order?.payUrl || !order?.orderId) return;
+
+                openDialog({
+                    title: t('pages.pricing.payFlow.goToPayTitle'),
+                    content: (
+                        <p className="text-white/90 text-sm leading-[1.43]">
+                            {t('pages.pricing.payFlow.goToPayDesc')}
+                        </p>
+                    ),
+                    buttons: [
+                        {
+                            label: t('pages.pricing.payFlow.goToPayButton'),
+                            variant: 'primary',
+                            onClick: (
+                                _e: React.MouseEvent<HTMLButtonElement>,
+                                dialogId: string
+                            ) => {
+                                window.open(order.payUrl, '_blank');
+                                closeDialog(dialogId);
+                                const waitId = openDialog({
+                                    title: t('pages.pricing.payFlow.waitingTitle'),
+                                    content: (
+                                        <PayWaitingContent
+                                            orderId={order.orderId}
+                                            onClose={() => closeDialog(waitId)}
+                                        />
+                                    ),
+                                    buttons: [
+                                        {
+                                            label: t('pages.pricing.payFlow.closeWaiting'),
+                                            variant: 'secondary',
+                                            onClick: (
+                                                _e: React.MouseEvent<HTMLButtonElement>,
+                                                dialogId: string
+                                            ) => {
+                                                closeDialog(dialogId);
+                                            },
+                                        },
+                                    ],
+                                    maskClosable: false,
+                                });
+                            },
+                        },
+                    ],
+                    maskClosable: true,
+                });
+            })
+            .catch(() => {});
+    }, [
+        selectedPlan,
+        selectedPayMethod,
+        t,
+        openDialog,
+        closeDialog,
+        deeplinkUrl,
+        downloadUrlByPlatform,
+    ]);
+
+    return {
+        handlePayNow,
+    };
+}