diff --git a/frontend/providers/costcenter/public/locales/en/common.json b/frontend/providers/costcenter/public/locales/en/common.json index d7d9220483d..0cfc42b04d8 100644 --- a/frontend/providers/costcenter/public/locales/en/common.json +++ b/frontend/providers/costcenter/public/locales/en/common.json @@ -241,5 +241,6 @@ "first_recharge_title": "Get Double with Initial Purchase!", "credit_purchase": "Credit Purchase", "remaining_balance": "Remaining Balance", - "custom_amount": "Custom Amount" + "custom_amount": "Custom Amount", + "pay with alipay": "Pay With Alipay" } diff --git a/frontend/providers/costcenter/public/locales/zh/common.json b/frontend/providers/costcenter/public/locales/zh/common.json index 0bca7f2394f..a8f558ebf17 100644 --- a/frontend/providers/costcenter/public/locales/zh/common.json +++ b/frontend/providers/costcenter/public/locales/zh/common.json @@ -241,5 +241,6 @@ "first_recharge_title": "首充双倍!", "credit_purchase": "余额充值", "remaining_balance": "当前余额", - "custom_amount": "输入金额" + "custom_amount": "输入金额", + "pay with alipay": "支付宝支付" } diff --git a/frontend/providers/costcenter/src/assert/alipay.svg b/frontend/providers/costcenter/src/assert/alipay.svg new file mode 100644 index 00000000000..e609b3aa20a --- /dev/null +++ b/frontend/providers/costcenter/src/assert/alipay.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/providers/costcenter/src/components/CheckoutForm.tsx b/frontend/providers/costcenter/src/components/CheckoutForm.tsx deleted file mode 100644 index e99e9bdda16..00000000000 --- a/frontend/providers/costcenter/src/components/CheckoutForm.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Spinner } from '@chakra-ui/react'; -// import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'; -import useEnvStore from '@/stores/env'; -import { FormEvent } from 'react'; -const CheckoutForm = (props: { url: string; sessionId: string }) => { - // const stripe = useStripe(); - // const elements = useElements(); - - const stripePromise = useEnvStore((s) => s.stripePromise); - const handleSubmit = async (event: FormEvent) => { - // We don't want to let default form submission happen here, - // which would refresh the page. - event.preventDefault(); - if (!stripePromise) { - // Stripe.js hasn't yet loaded. - // Make sure to disable form submission until Stripe.js has loaded. - return; - } - const res1 = await stripePromise; - if (!res1) return; - const res = await res1.redirectToCheckout({ - sessionId: props.sessionId - }); - }; - - return ; -}; - -export default CheckoutForm; diff --git a/frontend/providers/costcenter/src/components/RechargeModal.tsx b/frontend/providers/costcenter/src/components/RechargeModal.tsx deleted file mode 100644 index 003a048b30b..00000000000 --- a/frontend/providers/costcenter/src/components/RechargeModal.tsx +++ /dev/null @@ -1,742 +0,0 @@ -import vector from '@/assert/Vector.svg'; -import stripe_icon from '@/assert/bi_stripe.svg'; -import wechat_icon from '@/assert/ic_baseline-wechat.svg'; -import CurrencySymbol from '@/components/CurrencySymbol'; -import OuterLink from '@/components/outerLink'; -import { useCustomToast } from '@/hooks/useCustomToast'; -import useEnvStore from '@/stores/env'; -import useSessionStore from '@/stores/session'; -import { ApiResp } from '@/types/api'; -import { Pay, Payment } from '@/types/payment'; -import { deFormatMoney, formatMoney } from '@/utils/format'; -import { - Box, - Button, - Flex, - Img, - Modal, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - NumberDecrementStepper, - NumberIncrementStepper, - NumberInput, - NumberInputField, - NumberInputStepper, - SimpleGrid, - Spinner, - Text, - useDisclosure -} from '@chakra-ui/react'; -import { MyTooltip } from '@sealos/ui'; -import { Stripe } from '@stripe/stripe-js'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import type { AxiosInstance } from 'axios'; -import { isNumber } from 'lodash'; -import { useTranslation } from 'next-i18next'; -import { QRCodeSVG } from 'qrcode.react'; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; -import GiftIcon from './icons/GiftIcon'; -import HelpIcon from './icons/HelpIcon'; -const StripeForm = (props: { - tradeNO?: string; - complete: number; - stripePromise: Promise; -}) => { - const stripePromise = props.stripePromise; - const sessionId = props.tradeNO; - const complete = props.complete; - useEffect(() => { - if (stripePromise && sessionId) - (async () => { - try { - const res1 = await stripePromise; - if (!res1) return; - if (complete !== 2) return; - await res1.redirectToCheckout({ - sessionId - }); - } catch (e) { - console.error(e); - } - })(); - }, [sessionId, stripePromise, complete]); - return ( - - - - ); -}; - -function WechatPayment(props: { complete: number; codeURL?: string; tradeNO?: string }) { - const { t } = useTranslation(); - return ( - - - - {t('Scan with WeChat')} - - {props.complete === 2 && !!props.codeURL ? ( - - ) : ( - waiting... - )} - - - {t('Order Number')}: {props.tradeNO || ''} - - - {t('Payment Result')}:{props.complete === 3 ? t('Payment Successful') : t('In Payment')} - - - - - ); -} -const BonusBox = (props: { - onClick: () => void; - selected: boolean; - bouns: number; - isFirst?: boolean; - amount: number; -}) => { - const { t } = useTranslation(); - const currency = useEnvStore((s) => s.currency); - - return ( - { - e.preventDefault(); - props.onClick(); - }} - > - {props.isFirst ? ( - - - {t('Double')}! - - + - - {props.bouns} - - - - ) : props.bouns !== 0 ? ( - - {t('Bonus')} - - {props.bouns} - - ) : ( - <> - )} - - - - {props.amount} - - - - ); -}; -const RechargeModal = forwardRef( - ( - props: { - onPaySuccess?: () => void; - onPayError?: () => void; - onCreatedSuccess?: () => void; - onCreatedError?: () => void; - onCancel?: () => void; - balance: number; - stripePromise: Promise; - request: AxiosInstance; - }, - ref - ) => { - const balance = props.balance || 0; - const { t } = useTranslation(); - const { isOpen, onOpen, onClose: _onClose } = useDisclosure(); - const request = props.request; - useImperativeHandle( - ref, - () => ({ - onOpen: () => { - onOpen(); - }, - onClose: () => { - onClose(); - } - }), - [] - ); - - const [step, setStep] = useState(1); - - // 整个流程跑通需要状态管理, 0 初始态, 1 创建支付单, 2 支付中, 3 支付成功 - const [complete, setComplete] = useState<0 | 1 | 2 | 3>(0); - // 0 是微信,1 是stripe - const [payType, setPayType] = useState<'wechat' | 'stripe'>('wechat'); - // 计费详情 - const [detail, setDetail] = useState(false); - const [paymentName, setPaymentName] = useState(''); - const [selectAmount, setSelectAmount] = useState(0); - const createPaymentRes = useMutation( - () => - request.post>('/api/account/payment', { - amount: deFormatMoney(amount), - paymentMethod: payType - }), - { - onSuccess(data) { - setPaymentName((data?.data?.paymentName as string).trim()); - props.onCreatedSuccess?.(); - setComplete(2); - }, - onError(err: any) { - toast({ - status: 'error', - title: err?.message || '', - isClosable: true, - position: 'top' - }); - props.onCreatedError?.(); - setComplete(0); - } - } - ); - - const { data, isPreviousData } = useQuery( - ['query-charge-res', { id: paymentName }], - () => - request>('/api/account/payment/pay', { - params: { - id: paymentName - } - }), - { - refetchInterval: complete === 2 ? 1000 : false, - enabled: complete === 2, - cacheTime: 0, - staleTime: 0, - onSuccess(data) { - setTimeout(() => { - if ((data?.data?.status || '').toUpperCase() === 'SUCCESS') { - createPaymentRes.reset(); - setComplete(3); - props.onPaySuccess?.(); - onClose(); - setComplete(0); - } - }, 3000); - } - } - ); - const cancalPay = useCallback(() => { - createPaymentRes.reset(); - props.onCancel?.(); - setComplete(0); - }, [createPaymentRes]); - - const onClose = () => { - setDetail(false); - cancalPay(); - _onClose(); - }; - const { session } = useSessionStore(); - const { toast } = useCustomToast(); - const { data: bonuses, isSuccess } = useQuery( - ['bonus', session.user.id], - () => - request.post< - any, - ApiResp<{ - discount: { - defaultSteps: Record; - firstRechargeDiscount: Record; - }; - }> - >('/api/price/bonus'), - {} - ); - - const [defaultSteps, ratios, steps, specialBonus] = useMemo(() => { - const defaultSteps = Object.entries(bonuses?.data?.discount.defaultSteps || {}).sort( - (a, b) => +a[0] - +b[0] - ); - const ratios = defaultSteps.map(([key, value]) => value); - const steps = defaultSteps.map(([key, value]) => +key); - const specialBonus = Object.entries(bonuses?.data?.discount.firstRechargeDiscount || {}).sort( - (a, b) => +a[0] - +b[0] - ); - const temp: number[] = []; - specialBonus.forEach(([k, v]) => { - const step = +k; - if (steps.findIndex((v) => step === v) === -1) { - temp.push(+k); - } - }); - steps.unshift(...temp); - ratios.unshift(...temp.map(() => 0)); - return [defaultSteps, ratios, steps, specialBonus]; - }, [bonuses?.data?.discount.defaultSteps, bonuses?.data?.discount.firstRechargeDiscount]); - const [amount, setAmount] = useState(() => 0); - const getBonus = (amount: number) => { - let ratio = 0; - let specialIdx = specialBonus.findIndex(([k]) => +k === amount); - if (specialIdx >= 0) return Math.floor((amount * specialBonus[specialIdx][1]) / 100); - const step = [...steps].reverse().findIndex((step) => amount >= step); - if (ratios.length > step && step > -1) ratio = [...ratios].reverse()[step]; - return Math.floor((amount * ratio) / 100); - }; - const { stripeEnabled, wechatEnabled } = useEnvStore(); - useEffect(() => { - if (steps && steps.length > 0) { - const result = steps.map((v, idx) => [v, getBonus(v), idx]).filter(([k, v]) => v > 0); - if (result.length > 0) { - const [key, bouns, idx] = result[0]; - setSelectAmount(idx); - setAmount(key); - } - } - }, [steps]); - const handleWechatConfirm = () => { - setPayType('wechat'); - setComplete(1); - createPaymentRes.mutate(); - }; - - const handleStripeConfirm = () => { - setPayType('stripe'); - if (amount < 10) { - toast({ - status: 'error', - title: t('Pay Minimum Tips') - }); - // 校检,stripe有最低费用的要求 - return; - } - setComplete(1); - createPaymentRes.mutate(); - }; - const currency = useEnvStore((s) => s.currency); - return ( - - - - {!detail ? ( - complete === 0 ? ( - <> - - {t('credit_purchase')} - - - - - - {t('remaining_balance')} - - - - {formatMoney(balance).toFixed(2)} - - - - - - {t('Select Amount')} - - {specialBonus && specialBonus.length > 0 && ( - - - - {t('first_recharge_title')} - - - {t('first_recharge_tips')} - - } - > - - - - )} - - - {steps.map((amount, index) => ( - +a[0] === amount) >= 0} - bouns={getBonus(amount)} - onClick={() => { - setSelectAmount(index); - setAmount(amount); - }} - selected={selectAmount === index} - /> - ))} - - - - - {t('custom_amount')} - - { - const maxAmount = 10_000_000; - if (!str || !isNumber(v) || isNaN(v)) { - setAmount(0); - return; - } - if (v > maxAmount) { - setAmount(maxAmount); - return; - } - setAmount(v); - }} - > - - - - - - - - - - - - - - - {t('Bonus')} - - - - {getBonus(amount)} - - - setDetail(true)} - > - - - - {stripeEnabled && ( - - )} - {wechatEnabled && ( - - )} - - - - ) : ( - <> - - { - cancalPay(); - }} - > - {t('Recharge Amount')} - - - {payType === 'wechat' ? ( - - ) : ( - - )} - - ) - ) : ( - <> - - {t('preferential_rules')} - - - - - - {t('Recharge Amount')} - - - {t('preferential_strength')} - - {steps && - ratios && - steps.length === ratios.length && - steps - .map( - (step, idx, steps) => - [ - step, - idx < steps.length - 1 ? steps[idx + 1] : undefined, - ratios[idx] - ] as const - ) - .filter(([_, _2, ratio], idx) => { - return ratio > 0; - }) - - .map(([pre, next, ratio], idx) => ( - <> - - {pre} - {' <= '} - {t('Recharge Amount')} - {next ? `< ${next}` : ''} - - - {t('Bonus')} - {ratio.toFixed(2)}% - - - ))} - {specialBonus && - specialBonus.map(([k, v], i) => ( - <> - - {k} = {t('Recharge Amount')}{' '} - - - {t('Bonus')} {v} % - - - ))} - - - - )} - - - ); - } -); - -RechargeModal.displayName = 'RechargeModal'; - -export default RechargeModal; diff --git a/frontend/providers/costcenter/src/components/RechargeModal/AlipayForm.tsx b/frontend/providers/costcenter/src/components/RechargeModal/AlipayForm.tsx new file mode 100644 index 00000000000..bc0bf37cccc --- /dev/null +++ b/frontend/providers/costcenter/src/components/RechargeModal/AlipayForm.tsx @@ -0,0 +1,23 @@ +import { RechargePaymentState } from '@/constants/payment'; +import { Flex, Spinner } from '@chakra-ui/react'; +import { useEffect } from 'react'; + +const AlipayForm = ({ + codeURL: url, + rechargePhase +}: { + codeURL?: string; + rechargePhase: RechargePaymentState; +}) => { + useEffect(() => { + if (rechargePhase !== RechargePaymentState.PROCESSING || !url || !window.top) return; + window.top.location.replace(url); + }, [rechargePhase, url]); + return ( + + + + ); +}; + +export default AlipayForm; diff --git a/frontend/providers/costcenter/src/components/RechargeModal/BonusBox.tsx b/frontend/providers/costcenter/src/components/RechargeModal/BonusBox.tsx new file mode 100644 index 00000000000..cf2e4516638 --- /dev/null +++ b/frontend/providers/costcenter/src/components/RechargeModal/BonusBox.tsx @@ -0,0 +1,113 @@ +import useEnvStore from '@/stores/env'; +import { Flex, Text } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import CurrencySymbol from '../CurrencySymbol'; + +const BonusBox = (props: { + onClick: () => void; + selected: boolean; + bouns: number; + isFirst?: boolean; + amount: number; +}) => { + const { t } = useTranslation(); + const currency = useEnvStore((s) => s.currency); + + return ( + { + e.preventDefault(); + props.onClick(); + }} + > + {props.isFirst ? ( + + + {t('Double')}! + + + + + {props.bouns} + + + + ) : props.bouns !== 0 ? ( + + {t('Bonus')} + + {props.bouns} + + ) : ( + <> + )} + + + + {props.amount} + + + + ); +}; +export default BonusBox; diff --git a/frontend/providers/costcenter/src/components/RechargeModal/StripeForm.tsx b/frontend/providers/costcenter/src/components/RechargeModal/StripeForm.tsx new file mode 100644 index 00000000000..e364f4bd026 --- /dev/null +++ b/frontend/providers/costcenter/src/components/RechargeModal/StripeForm.tsx @@ -0,0 +1,37 @@ +import { RechargePaymentState } from '@/constants/payment'; +import { Flex, Spinner } from '@chakra-ui/react'; +import { Stripe } from '@stripe/stripe-js'; +import { useEffect } from 'react'; + +const StripeForm = ({ + tradeNO: sessionId, + rechargePhase, + stripePromise +}: { + tradeNO?: string; + rechargePhase: RechargePaymentState; + stripePromise: Promise; +}) => { + useEffect(() => { + if (stripePromise && sessionId) + (async () => { + try { + const res1 = await stripePromise; + if (!res1) return; + if (rechargePhase !== RechargePaymentState.PROCESSING) return; + await res1.redirectToCheckout({ + sessionId + }); + } catch (e) { + console.error(e); + } + })(); + }, [sessionId, stripePromise, rechargePhase]); + return ( + + + + ); +}; + +export default StripeForm; diff --git a/frontend/providers/costcenter/src/components/RechargeModal/WechatPayment.tsx b/frontend/providers/costcenter/src/components/RechargeModal/WechatPayment.tsx new file mode 100644 index 00000000000..24d3ab021cc --- /dev/null +++ b/frontend/providers/costcenter/src/components/RechargeModal/WechatPayment.tsx @@ -0,0 +1,68 @@ +import wechat_icon from '@/assert/ic_baseline-wechat.svg'; +import { RechargePaymentState } from '@/constants/payment'; +import { Box, Flex, Text } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { QRCodeSVG } from 'qrcode.react'; +export default function WechatPayment({ + rechargePhase, + codeURL, + tradeNO +}: { + rechargePhase: RechargePaymentState; + codeURL?: string; + tradeNO?: string; +}) { + const { t } = useTranslation(); + return ( + + + + {t('Scan with WeChat')} + + {rechargePhase === RechargePaymentState.PROCESSING && !!codeURL ? ( + + ) : ( + waiting... + )} + + + {t('Order Number')}: {tradeNO || ''} + + + {t('Payment Result')}: + {rechargePhase === RechargePaymentState.SUCCESS + ? t('Payment Successful') + : t('In Payment')} + + + + + ); +} diff --git a/frontend/providers/costcenter/src/components/RechargeModal/index.tsx b/frontend/providers/costcenter/src/components/RechargeModal/index.tsx new file mode 100644 index 00000000000..dfb562e2285 --- /dev/null +++ b/frontend/providers/costcenter/src/components/RechargeModal/index.tsx @@ -0,0 +1,592 @@ +import vector from '@/assert/Vector.svg'; +import alipay_icon from '@/assert/alipay.svg'; +import stripe_icon from '@/assert/bi_stripe.svg'; +import wechat_icon from '@/assert/ic_baseline-wechat.svg'; +import CurrencySymbol from '@/components/CurrencySymbol'; +import OuterLink from '@/components/outerLink'; +import { RechargePaymentState, RechargePaymentType } from '@/constants/payment'; +import { useCustomToast } from '@/hooks/useCustomToast'; +import useEnvStore from '@/stores/env'; +import useSessionStore from '@/stores/session'; +import { ApiResp } from '@/types/api'; +import { Pay, Payment, RechargeModalProps, RechargeModalRef } from '@/types/payment'; +import { deFormatMoney, formatMoney } from '@/utils/format'; +import { + Box, + Button, + ButtonGroup, + ButtonGroupProps, + Flex, + Img, + Modal, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, + SimpleGrid, + Text, + useDisclosure +} from '@chakra-ui/react'; +import { MyTooltip } from '@sealos/ui'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { isNumber } from 'lodash'; +import { useTranslation } from 'next-i18next'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; +import GiftIcon from '../icons/GiftIcon'; +import HelpIcon from '../icons/HelpIcon'; +import AlipayForm from './AlipayForm'; +import BonusBox from './BonusBox'; +import StripeForm from './StripeForm'; +import WechatPayment from './WechatPayment'; + +const RechargeModal = forwardRef((props, ref) => { + const { t } = useTranslation(); + const { isOpen, onOpen, onClose: _onClose } = useDisclosure(); + const { session } = useSessionStore(); + const { toast } = useCustomToast(); + const { stripeEnabled, wechatEnabled, alipayEnabled, currency } = useEnvStore(); + const enabledPaymentMethodsCount = [stripeEnabled, wechatEnabled, alipayEnabled].filter( + (x) => !!x + ).length; + const paymentButtonVarint: ButtonGroupProps['variant'] = + enabledPaymentMethodsCount <= 2 ? 'solid' : 'outline'; + const request = props.request; + const balance = props.balance || 0; + const [step, setStep] = useState(1); + const [payType, setPayType] = useState(RechargePaymentType.Wechat); + const [detail, setDetail] = useState(false); + const [paymentName, setPaymentName] = useState(''); + const [selectAmount, setSelectAmount] = useState(0); + const [amount, setAmount] = useState(0); + const [recharegPhase, setRecharegPhase] = useState( + RechargePaymentState.IDLE + ); + useImperativeHandle( + ref, + () => ({ + onOpen: () => { + onOpen(); + }, + onClose: () => { + onClose(); + } + }), + [] + ); + + const createPaymentMutation = useMutation( + () => + request.post>('/api/account/payment', { + amount: deFormatMoney(amount), + paymentMethod: payType + }), + { + onSuccess(data) { + setPaymentName((data?.data?.paymentName as string).trim()); + props.onCreatedSuccess?.(); + setRecharegPhase(RechargePaymentState.PROCESSING); + }, + onError(err: any) { + toast({ + status: 'error', + title: err?.message || '', + isClosable: true, + position: 'top' + }); + props.onCreatedError?.(); + setRecharegPhase(RechargePaymentState.IDLE); + } + } + ); + const { data, isPreviousData } = useQuery( + ['query-charge-res', { id: paymentName }], + () => + request>('/api/account/payment/pay', { + params: { + id: paymentName + } + }), + { + refetchInterval: recharegPhase === RechargePaymentState.PROCESSING ? 1000 : false, + enabled: recharegPhase === RechargePaymentState.PROCESSING, + cacheTime: 0, + staleTime: 0, + onSuccess(data) { + setTimeout(() => { + if ((data?.data?.status || '').toUpperCase() === 'SUCCESS') { + createPaymentMutation.reset(); + setRecharegPhase(RechargePaymentState.SUCCESS); + props.onPaySuccess?.(); + onClose(); + setRecharegPhase(RechargePaymentState.IDLE); + } + }, 3000); + } + } + ); + const cancalPay = () => { + createPaymentMutation.reset(); + props.onCancel?.(); + setRecharegPhase(RechargePaymentState.IDLE); + }; + const onClose = () => { + setDetail(false); + cancalPay(); + _onClose(); + }; + + const { data: bonuses, isSuccess } = useQuery( + ['bonus', session.user.id], + () => + request.post< + any, + ApiResp<{ + discount: { + defaultSteps: Record; + firstRechargeDiscount: Record; + }; + }> + >('/api/price/bonus'), + {} + ); + const [defaultSteps, ratios, steps, specialBonus] = useMemo(() => { + const defaultSteps = Object.entries(bonuses?.data?.discount.defaultSteps || {}).sort( + (a, b) => +a[0] - +b[0] + ); + const ratios = defaultSteps.map(([key, value]) => value); + const steps = defaultSteps.map(([key, value]) => +key); + const specialBonus = Object.entries(bonuses?.data?.discount.firstRechargeDiscount || {}).sort( + (a, b) => +a[0] - +b[0] + ); + const temp: number[] = []; + specialBonus.forEach(([k, v]) => { + const step = +k; + if (steps.findIndex((v) => step === v) === -1) { + temp.push(+k); + } + }); + steps.unshift(...temp); + ratios.unshift(...temp.map(() => 0)); + return [defaultSteps, ratios, steps, specialBonus]; + }, [bonuses?.data?.discount.defaultSteps, bonuses?.data?.discount.firstRechargeDiscount]); + const getBonus = (amount: number) => { + let ratio = 0; + let specialIdx = specialBonus.findIndex(([k]) => +k === amount); + if (specialIdx >= 0) return Math.floor((amount * specialBonus[specialIdx][1]) / 100); + const step = [...steps].reverse().findIndex((step) => amount >= step); + if (ratios.length > step && step > -1) ratio = [...ratios].reverse()[step]; + return Math.floor((amount * ratio) / 100); + }; + const handleWechatConfirm = () => { + setPayType(RechargePaymentType.Wechat); + setRecharegPhase(RechargePaymentState.CREATING); + createPaymentMutation.mutate(); + }; + const handleStripeConfirm = () => { + setPayType(RechargePaymentType.Stripe); + if (amount < 10) { + toast({ + status: 'error', + title: t('Pay Minimum Tips') + }); + // 校检,stripe有最低费用的要求 + return; + } + setRecharegPhase(RechargePaymentState.CREATING); + createPaymentMutation.mutate(); + }; + const handleAlipayConfirm = () => { + setPayType(RechargePaymentType.Alipay); + setRecharegPhase(RechargePaymentState.CREATING); + createPaymentMutation.mutate(); + }; + useEffect(() => { + if (steps && steps.length > 0) { + const result = steps.map((v, idx) => [v, getBonus(v), idx]).filter(([k, v]) => v > 0); + if (result.length > 0) { + const [key, bouns, idx] = result[0]; + setSelectAmount(idx); + setAmount(key); + } + } + }, [steps]); + return ( + + + + {!detail ? ( + RechargePaymentState.IDLE === recharegPhase ? ( + <> + + {t('credit_purchase')} + + + + + + {t('remaining_balance')} + + + + {formatMoney(balance).toFixed(2)} + + + + + + {t('Select Amount')} + + {specialBonus && specialBonus.length > 0 && ( + + + + {t('first_recharge_title')} + + + {t('first_recharge_tips')} + + } + > + + + + )} + + + {steps.map((amount, index) => ( + +a[0] === amount) >= 0} + bouns={getBonus(amount)} + onClick={() => { + setSelectAmount(index); + setAmount(amount); + }} + selected={selectAmount === index} + /> + ))} + + + + + {t('custom_amount')} + + { + const maxAmount = 10_000_000; + if (!str || !isNumber(v) || isNaN(v)) { + setAmount(0); + return; + } + if (v > maxAmount) { + setAmount(maxAmount); + return; + } + setAmount(v); + }} + > + + + + + + + + + + + + + + + {t('Bonus')} + + + + {getBonus(amount)} + + + setDetail(true)} + > + + + Button': { + py: '10px', + '> img': { + boxSize: '20px' + } + // color: '' + }, + gap: '8px' + } + : { + '> Button': { + py: '14px', + px: '34px', + '> img': { + boxSize: '24px' + } + }, + gap: '8px' + } + } + > + {stripeEnabled && ( + + )} + {wechatEnabled && ( + + )} + {alipayEnabled && ( + + )} + + + + ) : ( + <> + + { + cancalPay(); + }} + > + {t('Recharge Amount')} + + + {payType === RechargePaymentType.Wechat ? ( + + ) : payType === RechargePaymentType.Stripe ? ( + + ) : ( + + )} + + ) + ) : ( + <> + + {t('preferential_rules')} + + + + + + {t('Recharge Amount')} + + + {t('preferential_strength')} + + {steps && + ratios && + steps.length === ratios.length && + steps + .map( + (step, idx, steps) => + [ + step, + idx < steps.length - 1 ? steps[idx + 1] : undefined, + ratios[idx] + ] as const + ) + .filter(([_, _2, ratio], idx) => { + return ratio > 0; + }) + + .map(([pre, next, ratio], idx) => ( + <> + + {pre} + {' <= '} + {t('Recharge Amount')} + {next ? `< ${next}` : ''} + + + {t('Bonus')} + {ratio.toFixed(2)}% + + + ))} + {specialBonus && + specialBonus.map(([k, v], i) => ( + <> + + {k} = {t('Recharge Amount')}{' '} + + + {t('Bonus')} {v} % + + + ))} + + + + )} + + + ); +}); + +RechargeModal.displayName = 'RechargeModal'; + +export default RechargeModal; diff --git a/frontend/providers/costcenter/src/components/TransferModal.tsx b/frontend/providers/costcenter/src/components/TransferModal.tsx index 9f4bf914424..9880555209b 100644 --- a/frontend/providers/costcenter/src/components/TransferModal.tsx +++ b/frontend/providers/costcenter/src/components/TransferModal.tsx @@ -2,7 +2,12 @@ import vector from '@/assert/Vector.svg'; import Currencysymbol from '@/components/CurrencySymbol'; import request from '@/service/request'; import useEnvStore from '@/stores/env'; -import { TransferState, transferStatus } from '@/types/Transfer'; +import { + TransferModalProps, + TransferModalRef, + TransferState, + transferStatus +} from '@/types/Transfer'; import { ApiResp } from '@/types/api'; import { deFormatMoney, formatMoney } from '@/utils/format'; import { @@ -28,260 +33,249 @@ import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'next-i18next'; import { forwardRef, useImperativeHandle, useState } from 'react'; -const TransferModal = forwardRef( - ( - props: { - onTransferSuccess?: () => void; - onTransferError?: () => void; - onCancel?: () => void; - balance: number; - k8s_username: string; - }, - ref - ) => { - useImperativeHandle( - ref, - () => ({ - onOpen: () => { - onOpen(); - }, - onClose: () => { - onClose(); - } +const TransferModal = forwardRef((props, ref) => { + useImperativeHandle( + ref, + () => ({ + onOpen: () => { + onOpen(); + }, + onClose: () => { + onClose(); + } + }), + [] + ); + const currency = useEnvStore((s) => s.currency); + const { t } = useTranslation(); + const { isOpen, onOpen, onClose: _onClose } = useDisclosure(); + const [to, setTo] = useState(''); + const toast = useToast(); + const balance = props.balance; + const mutation = useMutation( + () => + request.post>('/api/account/transfer', { + amount: deFormatMoney(amount), + to }), - [] - ); - const currency = useEnvStore((s) => s.currency); - const { t } = useTranslation(); - const { isOpen, onOpen, onClose: _onClose } = useDisclosure(); - const [to, setTo] = useState(''); - const toast = useToast(); - const balance = props.balance; - const mutation = useMutation( - () => - request.post>('/api/account/transfer', { - amount: deFormatMoney(amount), - to - }), - { - onSuccess(data) { - mutation.reset(); - setTo(''); - setAmount(0); - if (data.data?.status.progress === TransferState.TransferStateFailed) - toast({ - status: 'error', - title: data.data?.status.reason, - isClosable: true, - position: 'top' - }); - else { - toast({ - status: 'success', - title: t('Transfer Success'), - isClosable: true, - duration: 2000, - position: 'top' - }); - props.onTransferSuccess?.(); - _onClose(); - } - }, - onError(err: any) { + { + onSuccess(data) { + mutation.reset(); + setTo(''); + setAmount(0); + if (data.data?.status.progress === TransferState.TransferStateFailed) toast({ status: 'error', - title: err?.message || '', + title: data.data?.status.reason, + isClosable: true, + position: 'top' + }); + else { + toast({ + status: 'success', + title: t('Transfer Success'), isClosable: true, + duration: 2000, position: 'top' }); - props.onTransferError?.(); + props.onTransferSuccess?.(); + _onClose(); } - } - ); - - const verify = () => { - let trim_to = to.trim(); - if (!trim_to || trim_to.length < 6) { + }, + onError(err: any) { toast({ status: 'error', - title: t('Recipient ID is invalid'), + title: err?.message || '', isClosable: true, position: 'top' }); - return false; - } - if ( - (trim_to !== props.k8s_username && trim_to.replace('ns-', '') === props.k8s_username) || - trim_to === props.k8s_username - ) { - toast({ - status: 'error', - title: t('The payee cannot be oneself'), - isClosable: true, - position: 'top' - }); - return false; - } - // amount 必须是整数 - if (!Number.isInteger(amount)) { - toast({ - status: 'error', - title: t('Transfer Amount must be a integer'), - isClosable: true, - position: 'top' - }); - return false; - } - if (deFormatMoney(amount + 10) > balance) { - toast({ - status: 'error', - title: t('Transfer Amount must be less than balance'), - isClosable: true, - position: 'top' - }); - return false; + props.onTransferError?.(); } + } + ); + + const verify = () => { + let trim_to = to.trim(); + if (!trim_to || trim_to.length < 6) { + toast({ + status: 'error', + title: t('Recipient ID is invalid'), + isClosable: true, + position: 'top' + }); + return false; + } + if ( + (trim_to !== props.k8s_username && trim_to.replace('ns-', '') === props.k8s_username) || + trim_to === props.k8s_username + ) { + toast({ + status: 'error', + title: t('The payee cannot be oneself'), + isClosable: true, + position: 'top' + }); + return false; + } + // amount 必须是整数 + if (!Number.isInteger(amount)) { + toast({ + status: 'error', + title: t('Transfer Amount must be a integer'), + isClosable: true, + position: 'top' + }); + return false; + } + if (deFormatMoney(amount + 10) > balance) { + toast({ + status: 'error', + title: t('Transfer Amount must be less than balance'), + isClosable: true, + position: 'top' + }); + return false; + } - return true; - }; - const onClose = () => { - _onClose(); - }; + return true; + }; + const onClose = () => { + _onClose(); + }; - const [amount, setAmount] = useState(() => 0); + const [amount, setAmount] = useState(() => 0); - const handleConfirm = () => { - if (!verify()) return; - mutation.mutate(); - }; - return ( - - - - { + if (!verify()) return; + mutation.mutate(); + }; + return ( + + + + + {t('Transfer')} + + + + - {t('Transfer')} - - - + { + e.preventDefault(); + setTo(e.target.value.trim()); + }} + isDisabled={mutation.isLoading} + /> + - - {t('Recipient ID')} + {t('Transfer Amount')} + + (str.trim() ? setAmount(v) : setAmount(0))} + > + + + + + + + + + + + + + + + {t('Balance')} - { - e.preventDefault(); - setTo(e.target.value.trim()); - }} - isDisabled={mutation.isLoading} - /> - - {t('Transfer Amount')} + + + {formatMoney(balance).toFixed(2)} - (str.trim() ? setAmount(v) : setAmount(0))} + - + {t('Transfer')} + - - - ); - } -); + + + + ); +}); TransferModal.displayName = 'TransferModal'; export default TransferModal; diff --git a/frontend/providers/costcenter/src/components/cost_overview/components/user.tsx b/frontend/providers/costcenter/src/components/cost_overview/components/user.tsx index 18ef0c2d361..32a33cb4eba 100644 --- a/frontend/providers/costcenter/src/components/cost_overview/components/user.tsx +++ b/frontend/providers/costcenter/src/components/cost_overview/components/user.tsx @@ -5,14 +5,15 @@ import { Box, Button, Center, Flex, Image, Stack, SystemStyleObject, Text } from import { useQueryClient } from '@tanstack/react-query'; import CurrencySymbol from '@/components/CurrencySymbol'; +import RechargeModal from '@/components/RechargeModal'; +import TransferModal from '@/components/TransferModal'; import { RechargeContext } from '@/pages/cost_overview'; import useEnvStore from '@/stores/env'; import useOverviewStore from '@/stores/overview'; +import { TransferModalRef } from '@/types'; import jsyaml from 'js-yaml'; import { useTranslation } from 'next-i18next'; import { memo, useContext, useEffect, useMemo, useRef } from 'react'; -import RechargeModal from '../../RechargeModal'; -import TransferModal from '../../TransferModal'; export default memo(function UserCard({ balance }: { balance: number }) { const getSession = useSessionStore((state) => state.getSession); @@ -34,7 +35,7 @@ export default memo(function UserCard({ balance }: { balance: number }) { const session = useSessionStore().getSession(); const rechargeRef = useContext(RechargeContext).rechargeRef; - const transferRef = useRef(); + const transferRef = useRef(null); const queryClient = useQueryClient(); useEffect(() => { // 加锁 @@ -114,7 +115,7 @@ export default memo(function UserCard({ balance }: { balance: number }) { color="black" onClick={(e) => { e.preventDefault(); - transferRef?.current!.onOpen(); + transferRef?.current?.onOpen(); }} > {t('Transfer')} diff --git a/frontend/providers/costcenter/src/constants/payment.ts b/frontend/providers/costcenter/src/constants/payment.ts index 316c30034ba..4c242762415 100644 --- a/frontend/providers/costcenter/src/constants/payment.ts +++ b/frontend/providers/costcenter/src/constants/payment.ts @@ -78,7 +78,17 @@ export const generateTransferCrd = ({ return ''; } }; - +export enum RechargePaymentState { + IDLE = 0, + CREATING = 1, + PROCESSING = 2, + SUCCESS = 3 +} +export enum RechargePaymentType { + Wechat = 'wechat', + Stripe = 'stripe', + Alipay = 'alipay' +} // years mock data export const INIT_YEAR = 2022; export const CURRENT_MONTH = '本月'; diff --git a/frontend/providers/costcenter/src/pages/_app.tsx b/frontend/providers/costcenter/src/pages/_app.tsx index 184a002bb54..b641e5f68e8 100644 --- a/frontend/providers/costcenter/src/pages/_app.tsx +++ b/frontend/providers/costcenter/src/pages/_app.tsx @@ -1,23 +1,23 @@ import Layout from '@/layout'; -import { sealosApp } from 'sealos-desktop-sdk/app'; -import { EVENT_NAME } from 'sealos-desktop-sdk'; -import '@/styles/globals.scss'; +import { Response as initDataRes } from '@/pages/api/platform/getAppConfig'; +import request from '@/service/request'; +import useAppTypeStore from '@/stores/appType'; +import useBillingStore from '@/stores/billing'; +import useEnvStore from '@/stores/env'; import { theme } from '@/styles/chakraTheme'; +import '@/styles/globals.scss'; +import { ApiResp } from '@/types/api'; import { ChakraProvider } from '@chakra-ui/react'; -import { Hydrate, QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; +import { Hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { appWithTranslation } from 'next-i18next'; import type { AppProps } from 'next/app'; import Router, { useRouter } from 'next/router'; import NProgress from 'nprogress'; import 'nprogress/nprogress.css'; -import 'react-day-picker/dist/style.css'; -import { appWithTranslation, i18n } from 'next-i18next'; import { useEffect } from 'react'; -import request from '@/service/request'; -import { ApiResp } from '@/types/api'; -import { Response as initDataRes } from '@/pages/api/platform/getAppConfig'; -import useEnvStore from '@/stores/env'; -import useAppTypeStore from '@/stores/appType'; -import useBillingStore from '@/stores/billing'; +import 'react-day-picker/dist/style.css'; +import { EVENT_NAME } from 'sealos-desktop-sdk'; +import { sealosApp } from 'sealos-desktop-sdk/app'; // Make sure to call `loadStripe` outside a component’s render to avoid // recreating the `Stripe` object on every render. @@ -71,6 +71,7 @@ const App = ({ Component, pageProps }: AppProps) => { state.setEnv('stripeEnabled', stripeE); stripeE && state.setStripe(data?.STRIPE_PUB || ''); state.setEnv('wechatEnabled', !!data?.WECHAT_ENABLED); + state.setEnv('alipayEnabled', !!data?.ALIPAY_ENABLED); } catch (error) { console.error('get init config error'); } diff --git a/frontend/providers/costcenter/src/pages/api/platform/getAppConfig.ts b/frontend/providers/costcenter/src/pages/api/platform/getAppConfig.ts index 024d627bba0..cd205807ef1 100644 --- a/frontend/providers/costcenter/src/pages/api/platform/getAppConfig.ts +++ b/frontend/providers/costcenter/src/pages/api/platform/getAppConfig.ts @@ -1,8 +1,8 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; -import * as yaml from 'js-yaml'; -import { readFileSync } from 'fs'; import { jsonRes } from '@/service/backend/response'; import { AppConfigType, DefaultAppConfig } from '@/types/config'; +import { readFileSync } from 'fs'; +import * as yaml from 'js-yaml'; +import type { NextApiRequest, NextApiResponse } from 'next'; export type Response = { REALNAME_RECHARGE_LIMIT: boolean; @@ -11,6 +11,7 @@ export type Response = { STRIPE_ENABLED: boolean; STRIPE_PUB: string; WECHAT_ENABLED: boolean; + ALIPAY_ENABLED: boolean; CURRENCY: 'shellCoin' | 'cny' | 'usd'; INVOICE_ENABLED: boolean; GPU_ENABLED: boolean; @@ -50,6 +51,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) RECHARGE_ENABLED: global.AppConfig.costCenter.recharge.enabled, TRANSFER_ENABLED: global.AppConfig.costCenter.transferEnabled, STRIPE_ENABLED: global.AppConfig.costCenter.recharge.payMethods.stripe.enabled, + ALIPAY_ENABLED: global.AppConfig.costCenter.recharge.payMethods.alipay.enabled, STRIPE_PUB: global.AppConfig.costCenter.recharge.payMethods.stripe.publicKey, WECHAT_ENABLED: global.AppConfig.costCenter.recharge?.payMethods?.wechat?.enabled || false, CURRENCY: global.AppConfig.costCenter.currencyType, diff --git a/frontend/providers/costcenter/src/pages/cost_overview/index.tsx b/frontend/providers/costcenter/src/pages/cost_overview/index.tsx index 1c97e073046..0109ade5db2 100644 --- a/frontend/providers/costcenter/src/pages/cost_overview/index.tsx +++ b/frontend/providers/costcenter/src/pages/cost_overview/index.tsx @@ -7,13 +7,14 @@ import useNotEnough from '@/hooks/useNotEnough'; import request from '@/service/request'; import useOverviewStore from '@/stores/overview'; import { ApiResp } from '@/types'; +import { RechargeModalRef } from '@/types/payment'; import { Box, Flex, useToast } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'next-i18next'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { useRouter } from 'next/router'; -import { MutableRefObject, createContext, useEffect, useRef } from 'react'; -export const RechargeContext = createContext<{ rechargeRef: MutableRefObject | null }>({ +import { RefObject, createContext, useEffect, useRef } from 'react'; +export const RechargeContext = createContext<{ rechargeRef: RefObject | null }>({ rechargeRef: null }); @@ -51,7 +52,7 @@ function CostOverview() { }, []); const { NotEnoughModal } = useNotEnough(); const totast = useToast(); - const rechargeRef = useRef(); + const rechargeRef = useRef(null); const { data: balance_raw } = useQuery({ queryKey: ['getAccount'], queryFn: () => diff --git a/frontend/providers/costcenter/src/stores/env.ts b/frontend/providers/costcenter/src/stores/env.ts index 9f5d9c60512..1f39c1c9a1d 100644 --- a/frontend/providers/costcenter/src/stores/env.ts +++ b/frontend/providers/costcenter/src/stores/env.ts @@ -5,6 +5,7 @@ type EnvState = { rechargeEnabled: boolean; transferEnabled: boolean; invoiceEnabled: boolean; + alipayEnabled: boolean; gpuEnabled: boolean; wechatEnabled: boolean; stripeEnabled: boolean; @@ -20,6 +21,7 @@ type EnvState = { const useEnvStore = create((set, get) => ({ realNameRechargeLimit: false, + alipayEnabled: false, rechargeEnabled: false, transferEnabled: false, invoiceEnabled: false, diff --git a/frontend/providers/costcenter/src/types/Transfer.ts b/frontend/providers/costcenter/src/types/Transfer.ts index 7bf780c32ce..38fe21e4453 100644 --- a/frontend/providers/costcenter/src/types/Transfer.ts +++ b/frontend/providers/costcenter/src/types/Transfer.ts @@ -15,3 +15,14 @@ export type transferStatus = { to: string; }; }; +export type TransferModalRef = { + onOpen: () => void; + onClose: () => void; +}; +export type TransferModalProps = { + onTransferSuccess?: () => void; + onTransferError?: () => void; + onCancel?: () => void; + balance: number; + k8s_username: string; +}; diff --git a/frontend/providers/costcenter/src/types/config.ts b/frontend/providers/costcenter/src/types/config.ts index d5262f00802..bbc40007f69 100644 --- a/frontend/providers/costcenter/src/types/config.ts +++ b/frontend/providers/costcenter/src/types/config.ts @@ -32,6 +32,9 @@ export type PayMethods = { wechat: { enabled: boolean; }; + alipay: { + enabled: boolean; + }; stripe: { enabled: boolean; publicKey: string; @@ -111,6 +114,9 @@ export var DefaultAppConfig: AppConfigType = { wechat: { enabled: false }, + alipay: { + enabled: false + }, stripe: { enabled: false, publicKey: '' diff --git a/frontend/providers/costcenter/src/types/payment.ts b/frontend/providers/costcenter/src/types/payment.ts index 54f1456cf96..f6d96a545ed 100644 --- a/frontend/providers/costcenter/src/types/payment.ts +++ b/frontend/providers/costcenter/src/types/payment.ts @@ -1,3 +1,6 @@ +import { Stripe } from '@stripe/stripe-js'; +import { AxiosInstance } from 'axios'; + export type Payment = { paymentName: string; extra: { @@ -16,3 +19,17 @@ export type Pay = { status?: 'Created' | 'SUCCESS'; tradeNO: string; }; +export type RechargeModalRef = { + onOpen: () => void; + onClose: () => void; +}; +export type RechargeModalProps = { + onPaySuccess?: () => void; + onPayError?: () => void; + onCreatedSuccess?: () => void; + onCreatedError?: () => void; + onCancel?: () => void; + balance: number; + stripePromise: Promise; + request: AxiosInstance; +};