diff --git a/frontend/packages/ui/src/theme/colors.ts b/frontend/packages/ui/src/theme/colors.ts index 0857f17af1e..d53d379d28a 100644 --- a/frontend/packages/ui/src/theme/colors.ts +++ b/frontend/packages/ui/src/theme/colors.ts @@ -72,6 +72,10 @@ const baseColors = { 800: '#005B9C', 900: '#004B82' }, + royalBlue: { + 100: '#E1EAFF', + 700: '#2B5FD9' + }, yellow: { 25: '#FFFDFA', 50: '#FFFAEB', diff --git a/frontend/providers/costcenter/public/locales/en/common.json b/frontend/providers/costcenter/public/locales/en/common.json index f5c2d960d45..5eaf94ad580 100644 --- a/frontend/providers/costcenter/public/locales/en/common.json +++ b/frontend/providers/costcenter/public/locales/en/common.json @@ -87,7 +87,7 @@ "Stripe Success": "pay with Stripe successfully", "Stripe Cancel": "cancel to pay with Stripe", "GPU Unit": "Card", - "port_unit": "", + "port_unit": "", "Gpu valuation": "GPU Price Table", "common valuation": "Basic Valuation", "Billing Details": "Billing Details", @@ -233,5 +233,8 @@ "duration": "duration", "GPU": "GPU", "usage": "usage", - "resource": "resource" + "resource": "resource", + "Double": "Double", + "first_recharge_tips": "Partial specifications can enjoy double the amount of the initial recharge.", + "first_recharge_title": "Double on First Recharge" } diff --git a/frontend/providers/costcenter/public/locales/zh/common.json b/frontend/providers/costcenter/public/locales/zh/common.json index 2678089b168..4fbce721f53 100644 --- a/frontend/providers/costcenter/public/locales/zh/common.json +++ b/frontend/providers/costcenter/public/locales/zh/common.json @@ -84,7 +84,7 @@ "Stripe Success": "Stripe 支付完成", "Stripe Cancel": "Stripe 支付取消", "GPU Unit": "卡", - "port_unit": "个", + "port_unit": "个", "Name": "名称", "Price": "价格", "Unit": "单位", @@ -233,5 +233,8 @@ "duration": "时间", "GPU": "GPU", "usage": "用量", - "resource": "资源" + "resource": "资源", + "Double": "双倍", + "first_recharge_tips": "部分规格首次充值可享双倍赠送金额", + "first_recharge_title": "首充双倍" } diff --git a/frontend/providers/costcenter/src/components/RechargeModal.tsx b/frontend/providers/costcenter/src/components/RechargeModal.tsx index cbfa3d1a146..259f0743dda 100644 --- a/frontend/providers/costcenter/src/components/RechargeModal.tsx +++ b/frontend/providers/costcenter/src/components/RechargeModal.tsx @@ -5,9 +5,9 @@ import { default as CurrencySymbol, default as Currencysymbol } from '@/componen 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 { getFavorable } from '@/utils/favorable'; import { deFormatMoney, formatMoney } from '@/utils/format'; import { Box, @@ -29,13 +29,16 @@ import { 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, useState } from '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; @@ -122,10 +125,12 @@ const BonusBox = (props: { onClick: () => void; selected: boolean; bouns: number; + isFirst?: boolean; amount: number; }) => { const { t } = useTranslation(); const currency = useEnvStore((s) => s.currency); + return ( - - {t('Bonus')} - - {props.bouns} - + {props.isFirst ? ( + + + {t('Double')}! + + + + + {props.bouns} + + + + ) : ( + + {t('Bonus')} + + {props.bouns} + + )} @@ -283,33 +329,51 @@ const RechargeModal = forwardRef( cancalPay(); _onClose(); }; - + const { session } = useSessionStore(); const { toast } = useCustomToast(); const { data: bonuses, isSuccess } = useQuery( - ['bonus'], + ['bonus', session.user.id], () => - request.get< + request.post< any, ApiResp<{ - steps: number[]; - ratios: number[]; - specialDiscount: [number, number][]; + discount: { + defaultSteps: Record; + firstRechargeDiscount: Record; + }; }> >('/api/price/bonus'), {} ); - const ratios = bonuses?.data?.ratios || []; - const steps = bonuses?.data?.steps || []; - const specialBonus = bonuses?.data?.specialDiscount; + const [defaultSteps, ratios, steps, specialBonus] = useMemo(() => { + const defaultSteps = Object.entries(bonuses?.data?.discount.defaultSteps || {}).toSorted( + (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 || {} + ).toSorted((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 = useCallback( - (amount: number) => { - if (isSuccess && ratios && steps && ratios.length === steps.length) - return getFavorable(steps, ratios, specialBonus)(amount); - else return 0; - }, - [isSuccess, ratios, steps, specialBonus] - ); + 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) { @@ -346,7 +410,6 @@ const RechargeModal = forwardRef( - - {t('Select Amount')} - + + + {t('Select Amount')} + + + + + {t('first_recharge_title')}! + + {t('first_recharge_tips')}}> + + + + {steps.map((amount, index) => ( +a[0] === amount) >= 0} bouns={getBonus(amount)} onClick={() => { setSelectAmount(index); @@ -419,11 +499,13 @@ const RechargeModal = forwardRef( variant={'unstyled'} onChange={(str, v) => { const maxAmount = 10_000_000; - if (!isNumber(v) || isNaN(v)) { + if (!str || !isNumber(v) || isNaN(v)) { setAmount(0); + return; } if (v > maxAmount) { setAmount(maxAmount); + return; } setAmount(v); }} @@ -506,12 +588,13 @@ const RechargeModal = forwardRef( ) : ( <> - {t('Recharge Amount')} + {t('Recharge Amount')} - + {payType === 'wechat' ? ( + + + + + + + + + + + + + + + ) +}); +export default GiftIcon; diff --git a/frontend/providers/costcenter/src/components/icons/HelpIcon.tsx b/frontend/providers/costcenter/src/components/icons/HelpIcon.tsx new file mode 100644 index 00000000000..245d97bb262 --- /dev/null +++ b/frontend/providers/costcenter/src/components/icons/HelpIcon.tsx @@ -0,0 +1,17 @@ +import { createIcon } from '@chakra-ui/react'; + +export const HelpIcon = createIcon({ + displayName: 'HelpIcon', + viewBox: '0 0 16 16', + path: ( + + + + ) +}); +export default HelpIcon; diff --git a/frontend/providers/costcenter/src/layout/index.tsx b/frontend/providers/costcenter/src/layout/index.tsx index f08b791245b..2c58b4b04c1 100644 --- a/frontend/providers/costcenter/src/layout/index.tsx +++ b/frontend/providers/costcenter/src/layout/index.tsx @@ -2,7 +2,6 @@ import useSessionStore from '@/stores/session'; import { Box, Flex, Link, Text } from '@chakra-ui/react'; import { useEffect, useState } from 'react'; import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app'; -import styles from './index.module.scss'; import SideBar from './sidebar'; export default function Layout({ children }: any) { diff --git a/frontend/providers/costcenter/src/layout/sidebar.tsx b/frontend/providers/costcenter/src/layout/sidebar.tsx index 7ca81b42aeb..7f9a59b14df 100644 --- a/frontend/providers/costcenter/src/layout/sidebar.tsx +++ b/frontend/providers/costcenter/src/layout/sidebar.tsx @@ -1,20 +1,20 @@ -import { Flex, Text, Img, Divider, Box } from '@chakra-ui/react'; -import { useRouter } from 'next/router'; +import dashbordIcon from '@/assert/dashboard.svg'; +import dashboard_a_icon from '@/assert/dashboard_black.svg'; import letter_icon from '@/assert/format_letter_spacing_standard.svg'; import letter_a_icon from '@/assert/format_letter_spacing_standard_black.svg'; -import receipt_icon from '@/assert/receipt_long.svg'; -import receipt_a_icon from '@/assert/receipt_long_black.svg'; +import invoice_a_icon from '@/assert/invoice-active.svg'; +import invoice_icon from '@/assert/invoice.svg'; import layers_icon from '@/assert/layers.svg'; import layers_a_icon from '@/assert/layers_black.svg'; import linechart_icon from '@/assert/lineChart.svg'; import linechart_a_icon from '@/assert/lineChart_black.svg'; -import invoice_icon from '@/assert/invoice.svg'; -import invoice_a_icon from '@/assert/invoice-active.svg'; -import dashbordIcon from '@/assert/dashboard.svg'; -import dashboard_a_icon from '@/assert/dashboard_black.svg'; -import type { StaticImageData } from 'next/image'; -import { useTranslation } from 'next-i18next'; +import receipt_icon from '@/assert/receipt_long.svg'; +import receipt_a_icon from '@/assert/receipt_long_black.svg'; import useEnvStore from '@/stores/env'; +import { Box, Divider, Flex, Img, Text } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import type { StaticImageData } from 'next/image'; +import { useRouter } from 'next/router'; type Menu = { id: string; @@ -88,8 +88,6 @@ export default function SideBar() { return ( {t(item.value)} - {[0, 2, 4].includes(idx) && } + {([0, 2].includes(idx) || (idx === 4 && invoiceEnabled)) && ( + + )} ); })} diff --git a/frontend/providers/costcenter/src/pages/api/price/bonus.ts b/frontend/providers/costcenter/src/pages/api/price/bonus.ts index d35bbcb47da..b1dd15ecd18 100644 --- a/frontend/providers/costcenter/src/pages/api/price/bonus.ts +++ b/frontend/providers/costcenter/src/pages/api/price/bonus.ts @@ -1,7 +1,5 @@ -import { authSession } from '@/service/backend/auth'; -import { ApplyYaml, CRDMeta, GetCRD, GetUserDefaultNameSpace } from '@/service/backend/kubernetes'; +import { makeAPIClientByHeader } from '@/service/backend/region'; import { jsonRes } from '@/service/backend/response'; -import * as yaml from 'js-yaml'; import type { NextApiRequest, NextApiResponse } from 'next'; export default async function handler(req: NextApiRequest, resp: NextApiResponse) { @@ -9,69 +7,24 @@ export default async function handler(req: NextApiRequest, resp: NextApiResponse if (!global.AppConfig.costCenter.recharge.enabled) { throw new Error('recharge is not enabled'); } - const kc = await authSession(req.headers); - const user = kc.getCurrentUser(); - if (user === null) { - return jsonRes(resp, { code: 403, message: 'user null' }); - } - const namespace = GetUserDefaultNameSpace(user.name); - const name = new Date().getTime() + 'bonusquery'; + const client = await makeAPIClientByHeader(req, resp); + if (!client) return null; - const meta: CRDMeta = { - group: 'account.sealos.io', - version: 'v1', - namespace, - plural: 'billinginfoqueries' - }; - const crdSchema = { - apiVersion: `account.sealos.io/v1`, - kind: 'BillingInfoQuery', - metadata: { - name, - namespace - }, - spec: { - queryType: 'Recharge' - } - }; - const result1 = await ApplyYaml(kc, yaml.dump(crdSchema)); - const result = await new Promise<{ - discountRates: number[]; - discountSteps: number[]; - specialDiscount: Record; - }>((resolve, reject) => { - let retry = 3; - const wrap = () => - GetCRD(kc, meta, name) - .then((res) => { - const body = res.body as { status: any }; - if (!body.status) return Promise.reject(); - const { result, status } = body.status as Record; - if (status.toLocaleLowerCase() === 'completed') resolve(JSON.parse(result)); - else return Promise.reject(); - }) - .catch(async (err) => { - if (retry-- >= 0) { - await new Promise((res) => setTimeout(res, 1000)); - await wrap(); - } else reject(err); - }); - wrap(); - }); - if (!result) + const response = await client.post<{ + discount: { + defaultSteps: Record; + firstRechargeDiscount: Record; + }; + }>('/account/v1alpha1/recharge-discount'); + const data = response.data; + if (!data || response.status !== 200) return jsonRes(resp, { code: 404, message: 'bonus is not found' }); return jsonRes(resp, { code: 200, - data: { - ratios: result.discountRates, - steps: result.discountSteps, - specialDiscount: Object.entries(result.specialDiscount || {}).map<[number, number]>( - ([k, v]) => [+k, v] - ) - } + data }); } catch (error) { console.log(error); diff --git a/frontend/providers/costcenter/src/pages/api/price/index.ts b/frontend/providers/costcenter/src/pages/api/price/index.ts deleted file mode 100644 index fafbf57cd23..00000000000 --- a/frontend/providers/costcenter/src/pages/api/price/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { authSession } from '@/service/backend/auth'; -import { CRDMeta, GetCRD, GetUserDefaultNameSpace } from '@/service/backend/kubernetes'; -import { jsonRes } from '@/service/backend/response'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { ApplyYaml } from '@/service/backend/kubernetes'; -import * as yaml from 'js-yaml'; -import { ValuationBillingRecord, ValuationData } from '@/types/valuation'; -export default async function handler(req: NextApiRequest, resp: NextApiResponse) { - try { - const kc = await authSession(req.headers); - - // get user account payment amount - const user = kc.getCurrentUser(); - if (user === null) { - return jsonRes(resp, { code: 403, message: 'user null' }); - } - const namespace = GetUserDefaultNameSpace(user.name); - const name = 'price'; - const crdSchema = { - apiVersion: `account.sealos.io/v1`, - kind: 'PriceQuery', - metadata: { - name, - namespace - }, - spec: {} - }; - const meta: CRDMeta = { - group: 'account.sealos.io', - version: 'v1', - namespace, - plural: 'pricequeries' - }; - try { - await ApplyYaml(kc, yaml.dump(crdSchema)); - } catch {} - const billingRecords = await new Promise((resolve, reject) => { - let retry = 3; - const wrap = () => - GetCRD(kc, meta, name) - .then((res) => { - const crd = res.body as ValuationData; - resolve(crd.status.billingRecords); - }) - .catch((err) => { - if (retry-- >= 0) wrap(); - else reject(err); - }); - wrap(); - }); - return jsonRes<{ billingRecords: ValuationBillingRecord[] }>(resp, { - code: 200, - data: { - billingRecords - } - }); - } catch (error) { - console.log(error); - jsonRes(resp, { code: 500, message: 'get price error' }); - } -} diff --git a/frontend/providers/costcenter/src/service/auth.ts b/frontend/providers/costcenter/src/service/auth.ts index c2bc5ed42d1..c271967630a 100644 --- a/frontend/providers/costcenter/src/service/auth.ts +++ b/frontend/providers/costcenter/src/service/auth.ts @@ -21,7 +21,6 @@ export const verifyJWT = (token: string, if (!token) return resolve(null); verify(token, secret, (err, payload) => { if (err) { - // console.log(err); resolve(null); } else if (!payload) { resolve(null); diff --git a/frontend/providers/costcenter/src/service/request.ts b/frontend/providers/costcenter/src/service/request.ts index 1a064104b90..541e505c283 100644 --- a/frontend/providers/costcenter/src/service/request.ts +++ b/frontend/providers/costcenter/src/service/request.ts @@ -14,7 +14,6 @@ request.interceptors.request.use( // auto append service prefix let _headers: RawAxiosRequestHeaders = config.headers || {}; const session = useSessionStore.getState().session; - if (config.url && config.url?.startsWith('/api/')) { _headers['Authorization'] = encodeURIComponent(session?.kubeconfig || ''); }