From 7fa3a57df459e4943143d1b76d8cf60d4eae7e79 Mon Sep 17 00:00:00 2001 From: "web@ppanel" Date: Wed, 12 Mar 2025 17:15:32 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(payment):=20Add=20bank=20card?= =?UTF-8?q?=20payment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/services/admin/index.ts | 2 +- apps/admin/services/common/index.ts | 2 +- apps/user/app/(main)/(user)/payment/page.tsx | 10 +- .../user/app/(main)/(user)/payment/stripe.tsx | 270 ++++++++++++++++-- .../components/subscribe/payment-methods.tsx | 19 +- apps/user/locales/cs-CZ/payment.json | 26 ++ apps/user/locales/de-DE/payment.json | 26 ++ apps/user/locales/en-US/payment.json | 26 ++ apps/user/locales/es-ES/payment.json | 26 ++ apps/user/locales/es-MX/payment.json | 26 ++ apps/user/locales/fa-IR/payment.json | 26 ++ apps/user/locales/fi-FI/payment.json | 26 ++ apps/user/locales/fr-FR/payment.json | 26 ++ apps/user/locales/hi-IN/payment.json | 26 ++ apps/user/locales/hu-HU/payment.json | 26 ++ apps/user/locales/ja-JP/payment.json | 26 ++ apps/user/locales/ko-KR/payment.json | 26 ++ apps/user/locales/no-NO/payment.json | 26 ++ apps/user/locales/pl-PL/payment.json | 26 ++ apps/user/locales/pt-BR/payment.json | 26 ++ apps/user/locales/request.ts | 1 + apps/user/locales/ro-RO/payment.json | 26 ++ apps/user/locales/ru-RU/payment.json | 26 ++ apps/user/locales/th-TH/payment.json | 26 ++ apps/user/locales/tr-TR/payment.json | 26 ++ apps/user/locales/uk-UA/payment.json | 26 ++ apps/user/locales/vi-VN/payment.json | 26 ++ apps/user/locales/zh-CN/payment.json | 26 ++ apps/user/locales/zh-HK/payment.json | 26 ++ apps/user/services/common/index.ts | 2 +- apps/user/services/user/index.ts | 2 +- 31 files changed, 867 insertions(+), 39 deletions(-) create mode 100644 apps/user/locales/cs-CZ/payment.json create mode 100644 apps/user/locales/de-DE/payment.json create mode 100644 apps/user/locales/en-US/payment.json create mode 100644 apps/user/locales/es-ES/payment.json create mode 100644 apps/user/locales/es-MX/payment.json create mode 100644 apps/user/locales/fa-IR/payment.json create mode 100644 apps/user/locales/fi-FI/payment.json create mode 100644 apps/user/locales/fr-FR/payment.json create mode 100644 apps/user/locales/hi-IN/payment.json create mode 100644 apps/user/locales/hu-HU/payment.json create mode 100644 apps/user/locales/ja-JP/payment.json create mode 100644 apps/user/locales/ko-KR/payment.json create mode 100644 apps/user/locales/no-NO/payment.json create mode 100644 apps/user/locales/pl-PL/payment.json create mode 100644 apps/user/locales/pt-BR/payment.json create mode 100644 apps/user/locales/ro-RO/payment.json create mode 100644 apps/user/locales/ru-RU/payment.json create mode 100644 apps/user/locales/th-TH/payment.json create mode 100644 apps/user/locales/tr-TR/payment.json create mode 100644 apps/user/locales/uk-UA/payment.json create mode 100644 apps/user/locales/vi-VN/payment.json create mode 100644 apps/user/locales/zh-CN/payment.json create mode 100644 apps/user/locales/zh-HK/payment.json diff --git a/apps/admin/services/admin/index.ts b/apps/admin/services/admin/index.ts index 37cdd29..f9e3438 100644 --- a/apps/admin/services/admin/index.ts +++ b/apps/admin/services/admin/index.ts @@ -1,5 +1,5 @@ // @ts-ignore - + // API 更新时间: // API 唯一标识: import * as ads from './ads'; diff --git a/apps/admin/services/common/index.ts b/apps/admin/services/common/index.ts index 61ba129..73b3bda 100644 --- a/apps/admin/services/common/index.ts +++ b/apps/admin/services/common/index.ts @@ -1,5 +1,5 @@ // @ts-ignore - + // API 更新时间: // API 唯一标识: import * as auth from './auth'; diff --git a/apps/user/app/(main)/(user)/payment/page.tsx b/apps/user/app/(main)/(user)/payment/page.tsx index f584adc..5075d24 100644 --- a/apps/user/app/(main)/(user)/payment/page.tsx +++ b/apps/user/app/(main)/(user)/payment/page.tsx @@ -106,7 +106,7 @@ export default function Page() {
- {data?.method && {t(`methods.${data?.method}`)}} + {data?.method && {data?.method}}
@@ -223,19 +223,19 @@ export default function Page() { )} - {data?.status === 1 && payment?.type === 'stripe' && ( + {data?.status === 1 && payment?.type === 'Stripe' && (
-

{t('scanToPay')}

+

{t('waitingForPayment')}

{countdownDisplay}

{payment.stripe && } -
+ {/*
-
+
*/}
)} diff --git a/apps/user/app/(main)/(user)/payment/stripe.tsx b/apps/user/app/(main)/(user)/payment/stripe.tsx index 62f5741..f1eb134 100644 --- a/apps/user/app/(main)/(user)/payment/stripe.tsx +++ b/apps/user/app/(main)/(user)/payment/stripe.tsx @@ -1,5 +1,23 @@ -import { Elements, useStripe } from '@stripe/react-stripe-js'; -import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js'; +import { + CardCvcElement, + CardExpiryElement, + CardNumberElement, + Elements, + useElements, + useStripe, +} from '@stripe/react-stripe-js'; +import { + loadStripe, + PaymentIntentResult, + StripeCardNumberElementOptions, + StripeElementStyle, +} from '@stripe/stripe-js'; +import { Button } from '@workspace/ui/components/button'; +import { Input } from '@workspace/ui/components/input'; +import { Label } from '@workspace/ui/components/label'; +import { CheckCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useTheme } from 'next-themes'; import { QRCodeCanvas } from 'qrcode.react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -9,6 +27,196 @@ interface StripePaymentProps { publishable_key: string; } +interface CardPaymentFormProps { + clientSecret: string; + onError: (message: string) => void; +} + +const CardPaymentForm: React.FC = ({ clientSecret, onError }) => { + const stripe = useStripe(); + const { theme, systemTheme } = useTheme(); + const elements = useElements(); + const [processing, setProcessing] = useState(false); + const [succeeded, setSucceeded] = useState(false); + const [errors, setErrors] = useState<{ + cardNumber?: string; + cardExpiry?: string; + cardCvc?: string; + name?: string; + }>({}); + const [cardholderName, setCardholderName] = useState(''); + const t = useTranslations('payment.stripe.card'); + + const currentTheme = theme === 'system' ? systemTheme : theme; + const elementStyle: StripeElementStyle = { + base: { + 'fontSize': '16px', + 'color': currentTheme === 'dark' ? '#fff' : '#000', + '::placeholder': { + color: '#aab7c4', + }, + }, + invalid: { + color: '#EF4444', + iconColor: '#EF4444', + }, + }; + + const elementOptions: StripeCardNumberElementOptions = { + style: elementStyle, + showIcon: true, + }; + + const handleChange = (event: any, field: keyof typeof errors) => { + if (event.error) { + setErrors((prev) => ({ ...prev, [field]: event.error.message })); + } else { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + onError(t('loading')); + return; + } + + if (!cardholderName.trim()) { + setErrors((prev) => ({ ...prev, name: t('name_required') })); + return; + } + + setProcessing(true); + + const cardNumber = elements.getElement(CardNumberElement); + const cardExpiry = elements.getElement(CardExpiryElement); + const cardCvc = elements.getElement(CardCvcElement); + + if (!cardNumber || !cardExpiry || !cardCvc) { + onError(t('element_error')); + setProcessing(false); + return; + } + + const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, { + payment_method: { + card: cardNumber, + billing_details: { + name: cardholderName, + }, + }, + }); + + if (error) { + onError(error.message || t('payment_failed')); + setProcessing(false); + } else if (paymentIntent && paymentIntent.status === 'succeeded') { + setSucceeded(true); + setProcessing(false); + } else { + onError(t('processing')); + setProcessing(false); + } + }; + + return ( +
+ {succeeded ? ( +
+
+ +
+

{t('success_title')}

+

{t('success_message')}

+
+ ) : ( + <> +
+ {/* Cardholder Name */} +
+ + setCardholderName(e.target.value)} + placeholder={t('name_placeholder')} + className={errors.name ? 'border-red-500' : ''} + /> + {errors.name &&

{errors.name}

} +
+ + {/* Card Number */} +
+ +
+
+ handleChange(e, 'cardNumber')} + /> +
+
+ {errors.cardNumber &&

{errors.cardNumber}

} +
+ +
+ {/* Expiry Date */} +
+ +
+ handleChange(e, 'cardExpiry')} + /> +
+ {errors.cardExpiry &&

{errors.cardExpiry}

} +
+ + {/* Security Code */} +
+ +
+ handleChange(e, 'cardCvc')} + /> +
+ {errors.cardCvc &&

{errors.cardCvc}

} +
+
+
+
+ +

{t('secure_notice')}

+
+ + )} +
+ ); +}; + const StripePayment: React.FC = ({ method, client_secret, @@ -31,6 +239,7 @@ const CheckoutForm: React.FC> = ({ const [errorMessage, setErrorMessage] = useState(null); const [qrCodeUrl, setQrCodeUrl] = useState(null); const [isSubmitted, setIsSubmitted] = useState(false); + const t = useTranslations('payment.stripe'); const handleError = useCallback((message: string) => { setErrorMessage(message); @@ -39,7 +248,7 @@ const CheckoutForm: React.FC> = ({ const confirmPayment = useCallback(async (): Promise => { if (!stripe) { - handleError('Stripe.js is not loaded.'); + handleError(t('card.loading')); return null; } @@ -50,18 +259,20 @@ const CheckoutForm: React.FC> = ({ { handleActions: false }, ); } - - return await stripe.confirmWechatPayPayment( - client_secret, - { - payment_method_options: { wechat_pay: { client: 'web' } }, - }, - { handleActions: false }, - ); - }, [client_secret, method, stripe, handleError]); + if (method === 'wechat_pay') { + return await stripe.confirmWechatPayPayment( + client_secret, + { + payment_method_options: { wechat_pay: { client: 'web' } }, + }, + { handleActions: false }, + ); + } + return null; + }, [client_secret, method, stripe, handleError, t]); const autoSubmit = useCallback(async () => { - if (isSubmitted) return; + if (isSubmitted || method === 'card') return; setIsSubmitted(true); @@ -82,25 +293,32 @@ const CheckoutForm: React.FC> = ({ setQrCodeUrl(qrUrl || null); } } catch (error) { - handleError('An unexpected error occurred'); + handleError(t('error')); } - }, [confirmPayment, isSubmitted, handleError, method]); + }, [confirmPayment, isSubmitted, handleError, method, t]); useEffect(() => { autoSubmit(); }, [autoSubmit]); - return qrCodeUrl ? ( - + return method === 'card' ? ( +
+ +
+ ) : qrCodeUrl ? ( + <> + +

{t(`qrcode.${method}`)}

+ ) : ( errorMessage ); diff --git a/apps/user/components/subscribe/payment-methods.tsx b/apps/user/components/subscribe/payment-methods.tsx index 63f022d..b2a5c25 100644 --- a/apps/user/components/subscribe/payment-methods.tsx +++ b/apps/user/components/subscribe/payment-methods.tsx @@ -4,6 +4,7 @@ import { getAvailablePaymentMethods } from '@/services/user/portal'; import { useQuery } from '@tanstack/react-query'; import { Label } from '@workspace/ui/components/label'; import { RadioGroup, RadioGroupItem } from '@workspace/ui/components/radio-group'; +import { cn } from '@workspace/ui/lib/utils'; import { useTranslations } from 'next-intl'; import Image from 'next/image'; import React, { memo } from 'react'; @@ -33,14 +34,24 @@ const PaymentMethods: React.FC = ({ value, onChange, balanc onChange(Number(val))} + onValueChange={(val) => { + console.log(val); + onChange(Number(val)); + }} > {data?.map((item) => ( -
- +
+