diff --git a/src/components/composites/purchase/custom/page.tsx b/src/components/composites/purchase/custom/page.tsx deleted file mode 100644 index ba8fbff..0000000 --- a/src/components/composites/purchase/custom/page.tsx +++ /dev/null @@ -1,244 +0,0 @@ -'use client' -import {Form, FormField} from '@/components/ui/form' -import {Input} from '@/components/ui/input' -import {Button} from '@/components/ui/button' -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select' -import {useForm} from 'react-hook-form' -import {z} from 'zod' -import {zodResolver} from '@hookform/resolvers/zod' -import Image from 'next/image' -import check from '@/assets/check-accent.svg' -import banner from '../_assets/Mask-group.webp' -import group from '../_assets/Group.webp' -import {merge} from '@/lib/utils' -import FreeTrial from '@/components/free-trial' - -const formSchema = z.object({ - companyName: z.string().min(2, '企业名称至少2个字符'), - contactName: z.string().min(2, '联系人姓名至少2个字符'), - phone: z.string().min(11, '请输入11位手机号码').max(11, '手机号码长度不正确'), - monthlyUsage: z.string().min(1, '请选择您需要的用量'), - purpose: z.string().min(1, '输入用途'), -}) - -type FormValues = z.infer - -export default function CollectPage() { - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - companyName: '', - contactName: '', - phone: '', - monthlyUsage: '', - purpose: '', - }, - }) - - return ( - <> -
-
-

优质代理IP服务商

-

- 以技术升级为核心,提供优质的IP代理使用体验 -

-
- -
-
-
- 宣传图 -
-
- -
-

- 华连科技公司专注代理IP领域,多年来凭借专业技术与不懈努力,在行业内树立起良好口碑,为众多客户解决网络访问难题。公司拥有海量优质IP资源,涵盖全球多地,能精准匹配不同客户需求,无论是数据采集、网络营销还是突破地域限制,都能提供合适方案。凭借智能分配系统与严密安全防护,确保代理IP稳定、高效、安全运行,让用户使用过程顺畅无忧,数据安全有保障。秉持以客户为中心理念,配备专业客服与技术团队,提供7×24小时服务,助力企业与个人在网络世界畅行无阻,不断开拓业务新边界。 -

- -
- -
- -
-
- 特性 - IP时效3-30分钟(可定制) -
-
- 特性 - IP时效3-30分钟(可定制) -
-
- 特性 - IP时效3-30分钟(可定制) -
-
- 特性 - 支持高并发提取 -
-
- 特性 - 支持高并发提取 -
-
- 特性 - 支持高并发提取 -
-
-
-
-
- -
-

企业基本信息

-
- -
-
-
- {/* 企业名称 */} - - {({id, field}) => ( -
- - -
- )} -
- - {/* 联系人姓名 */} - - {({id, field}) => ( -
- - -
- )} -
- - {/* 联系人手机号码 */} - - {({id, field}) => ( -
- - -
- )} -
- - {/* 每月需求用量 */} - - {({id, field}) => ( -
- - -
- )} -
- - {/* 用途 */} - - {({id, field}) => ( -
- - -
- )} -
- -
- -
-
-
-
- -
-
-
-
-
-
- 现在注册,免费领取5000IP -
- -
-
-
-
- - ) -} diff --git a/src/components/composites/purchase/index.tsx b/src/components/composites/purchase/index.tsx index 975c01e..47931fb 100644 --- a/src/components/composites/purchase/index.tsx +++ b/src/components/composites/purchase/index.tsx @@ -41,7 +41,6 @@ export default function Purchase() { const componentMap: Record> = { short: ShortForm, long: LongForm, - // static: StaticForm } return (
diff --git a/src/components/composites/purchase/long/center.tsx b/src/components/composites/purchase/long/center.tsx index 1ff6fa7..2d51d67 100644 --- a/src/components/composites/purchase/long/center.tsx +++ b/src/components/composites/purchase/long/center.tsx @@ -1,292 +1,96 @@ 'use client' import {FormField} from '@/components/ui/form' import {RadioGroup} from '@/components/ui/radio-group' -import {Input} from '@/components/ui/input' -import {Button} from '@/components/ui/button' -import {Minus, Plus} from 'lucide-react' import FormOption from '@/components/composites/purchase/option' -import Image from 'next/image' -import check from '../_assets/check.svg' import {Schema} from '@/components/composites/purchase/long/form' -import {useFormContext, useWatch} from 'react-hook-form' +import {useWatch} from 'react-hook-form' import {Card} from '@/components/ui/card' +import {BillingMethodField} from '../shared/billing-method-field' +import {FeatureList} from '../shared/feature-list' +import {NumberStepperField} from '../shared/number-stepper-field' +import {getPurchaseSkuPrice} from '../shared/sku' -export default function Center({map, expireList, liveList}: { - map: Map +export default function Center({priceMap, expireList, liveList}: { + priceMap: Map liveList: string[] expireList: string[] }) { - const form = useFormContext() - const type = useWatch({name: 'type'}) + const type = useWatch({name: 'type'}) as Schema['type'] + const expire = useWatch({name: 'expire'}) as Schema['expire'] return ( + - {/* 计费方式 */} - {/* IP 时效 */} - - - {/* 根据套餐类型显示不同表单项 */} - {type === '2' ? ( - /* 包量:IP 购买数量 */ - - {({id, field}) => { - const value = Number(field.value) || 500 - const minValue = 500 - const step = 100 - return ( -
- - { - const value = Number(e.target.value) - if (value < 500) { - form.setValue('quota', 500) - } - }} - /> - -
- ) - }} -
- ) : ( - <> - {/* 包时:套餐时效 */} - - - {/* 包时:每日提取上限 */} - - {({id, field}) => { - const value = Number(field.value) || 100 - const minValue = 100 - const step = 100 - + + className="space-y-4" + name="live" + label="IP 时效"> + {({id, field}) => ( + + {liveList.map((live) => { + const price = getPurchaseSkuPrice(priceMap, { + mode: type, + live, + expire: String(expire), + }) return ( -
- - - { - const value = Number(e.target.value) - if (value < 100) { - form.setValue('daily_limit', 100) - } - }} - /> - - -
+ ) - }} -
- + })} + + )} + + + {/* 套餐时效 */} + {type === '1' && ( + + {({id, field}) => ( + + {expireList.map(day => ( + + ))} + + )} + )} - {/* 产品特性 */} -
-

产品特性

-
-

- check - 支持高并发提取 -

-

- check - 指定省份、城市或混播 -

-

- check - 账密+白名单验证 -

-

- check - 完备的API接口 -

-

- check - IP时效3-30分钟(可定制) -

-

- check - IP资源定期筛选 -

-

- check - 完备的API接口 -

-

- check - 包量/包时计费方式 -

-

- check - 每日去重量:500万 -

-
-
+ {/* 每日提取上限/购买数量 */} + {type === '1' ? ( + + ) : ( + + )} + +
) } - -function BillingMethod(props: { - expireList: string[] -}) { - const {setValue} = useFormContext() - return ( - - {({id, field}) => ( - { - field.onChange(v) - if (v === '2') { - setValue('expire', '0') - } - else if (props.expireList.length > 0) { - setValue('expire', props.expireList[0]) - } - }} - className="flex gap-4 max-md:flex-col"> - - - - - - - )} - - ) -} - -function IpTime({map, liveList}: { - map: Map - liveList: string[]}) { - const {control, getValues} = useFormContext() - const values = useWatch({control}) - - return ( - - {({id, field}) => ( - - {liveList.map((live) => { - const params = new URLSearchParams() - params.set('mode', { - 1: 'time', - 2: 'quota', - }[values.type || '2']) - params.set('live', live || '0') - params.set('expire', values.expire || '0') - const price = map.get(params.toString()) - return ( - - ) - })} - - - )} - - ) -} - -function ComboValidity({expireList}: {expireList: string[]}) { - return ( - - {({id, field}) => ( - { - field.onChange(val) - }} - className="flex gap-4 flex-wrap" - > - {expireList.map(item => ( - - ))} - - )} - - ) -} diff --git a/src/components/composites/purchase/long/form.tsx b/src/components/composites/purchase/long/form.tsx index 03e5c63..bac991d 100644 --- a/src/components/composites/purchase/long/form.tsx +++ b/src/components/composites/purchase/long/form.tsx @@ -1,13 +1,13 @@ 'use client' import {useForm} from 'react-hook-form' import Center from '@/components/composites/purchase/long/center' -import Right from '@/components/composites/purchase/long/right' import {Form} from '@/components/ui/form' import * as z from 'zod' import {zodResolver} from '@hookform/resolvers/zod' import {ProductItem} from '@/actions/product' +import {parsePurchaseSkuList} from '../shared/sku' +import {PurchaseSidePanel} from '../shared/side-panel' -// 定义表单验证架构 const schema = z.object({ type: z.enum(['1', '2']).default('2'), live: z.string(), @@ -16,27 +16,10 @@ const schema = z.object({ daily_limit: z.number().min(100, '每日限额不能少于 100 个'), pay_type: z.enum(['wechat', 'alipay', 'balance']), }) - -// 从架构中推断类型 export type Schema = z.infer export default function LongForm({skuList}: {skuList: ProductItem['skus']}) { - if (!skuList) throw new Error('没有套餐数据') - - const map = new Map() - // const _modeList = new Set() - const _liveList = new Set() - const _expireList = new Set() - for (const sku of skuList) { - const params = new URLSearchParams(sku.code) - // _modeList.add(params.get('mode') || '') - _liveList.add(Number(params.get('live'))) - _expireList.add(Number(params.get('expire'))) - map.set(sku.code, sku.price) - } - // const modeList = Array.from(_modeList).filter(Boolean) - const liveList = Array.from(_liveList).filter(Boolean).map(String) - const expireList = Array.from(_expireList).filter(Boolean).map(String) + const {priceMap, liveList, expireList} = parsePurchaseSkuList('long', skuList) const form = useForm({ resolver: zodResolver(schema), @@ -52,8 +35,8 @@ export default function LongForm({skuList}: {skuList: ProductItem['skus']}) { return (
-
- +
+ ) } diff --git a/src/components/composites/purchase/long/right.tsx b/src/components/composites/purchase/long/right.tsx deleted file mode 100644 index e1d191b..0000000 --- a/src/components/composites/purchase/long/right.tsx +++ /dev/null @@ -1,209 +0,0 @@ -'use client' -import {Suspense, use, useEffect, useState} from 'react' -import {useProfileStore} from '@/components/stores/profile' -import Pay from '@/components/composites/purchase/pay' -import {buttonVariants} from '@/components/ui/button' -import Link from 'next/link' -import {merge} from '@/lib/utils' -import {useFormContext, useWatch} from 'react-hook-form' -import {Schema} from '@/components/composites/purchase/long/form' -import {Card} from '@/components/ui/card' -import {getPrice, getPriceHome} from '@/actions/resource' -import {ExtraResp} from '@/lib/api' -import {FieldPayment} from '../shared/field-payment' - -export default function Right() { - const {control} = useFormContext() - const method = useWatch({control, name: 'pay_type'}) - const mode = useWatch({control, name: 'type'}) - const live = useWatch({control, name: 'live'}) - const quota = useWatch({control, name: 'quota'}) - const expire = useWatch({control, name: 'expire'}) - const dailyLimit = useWatch({control, name: 'daily_limit'}) - const [priceData, setPriceData] = useState>({ - price: '0.00', - actual: '0.00', - discounted: '0.00', - }) - const profile = use(useProfileStore(store => store.profile)) - - useEffect(() => { - const price = async () => { - try { - const resp = profile - ? await getPrice({ - type: 2, - long: { - live: Number(live), - mode: Number(mode), - quota: mode === '1' ? Number(dailyLimit) : Number(quota), - expire: mode === '1' ? Number(expire) : undefined, - }, - }) : await getPriceHome({ - type: 1, - short: { - live: Number(live), - mode: Number(mode), - quota: mode === '1' ? Number(dailyLimit) : Number(quota), - expire: mode === '1' ? Number(expire) : undefined, - }, - }) - if (!resp.success) { - throw new Error('获取价格失败') - } - setPriceData({ - price: resp.data.price, - actual: resp.data.actual ?? resp.data.price ?? '', - discounted: resp.data.discounted, - }) - } - catch (error) { - console.error('获取价格失败:', error) - setPriceData({ - price: '0.00', - actual: '0.00', - discounted: '0.00', - }) - } - } - price() - }, [dailyLimit, expire, live, quota, mode, profile]) - - const {price, actual: discountedPrice = ''} = priceData - // 计算总折扣价(原价 - 实付价格) - const calculateTotalDiscount = () => { - const originalPrice = parseFloat(price) - const actualPrice = parseFloat(discountedPrice) - if (isNaN(originalPrice) || isNaN(actualPrice)) { - return '0.00' - } - const discount = originalPrice - actualPrice - return discount.toFixed(2) - } - - const totalDiscount = calculateTotalDiscount() - const hasDiscount = parseFloat(totalDiscount) > 0 - return ( - -

订单详情

-
    -
  • - 套餐名称 - - {mode === '2' ? `包量套餐` : `包时套餐`} - -
  • -
  • - IP 时效 - - {live} - {' '} - 分钟 - -
  • - {mode === '2' ? ( - <> -
  • - 购买 IP 量 - - {quota} - 个 - -
  • -
  • - 原价 - - ¥{price} - -
  • - {hasDiscount && ( -
  • - 总折扣 - - -¥{totalDiscount} - -
  • - )} - - ) : ( - <> -
  • - 套餐时长 - - {expire} - 天 - -
  • -
  • - 每日限额 - - {dailyLimit} - 个 - -
  • -
  • - 原价 - - ¥{price} - -
  • - {hasDiscount && ( -
  • - 总折扣 - - -¥{totalDiscount} - -
  • - )} - - )} -
-
-

- 实付价格 - - ¥{discountedPrice} - -

- - - -
- ) -} - -function BalanceOrLogin(props: { - method: 'wechat' | 'alipay' | 'balance' - discountedPrice: string - mode: string - live: string - quota: number - expire: string - dailyLimit: number -}) { - const profile = use(useProfileStore(store => store.profile)) - return profile ? ( - <> - - - - ) : ( - - 登录后支付 - - ) -} diff --git a/src/components/composites/purchase/pay.tsx b/src/components/composites/purchase/pay.tsx index b779b48..97997e2 100644 --- a/src/components/composites/purchase/pay.tsx +++ b/src/components/composites/purchase/pay.tsx @@ -15,7 +15,6 @@ import { } from '@/lib/models/trade' import {PaymentModal} from '@/components/composites/payment/payment-modal' import {PaymentProps} from '@/components/composites/payment/type' -import {usePlatformType} from '@/lib/hooks' export type PayProps = { amount: string @@ -32,36 +31,35 @@ export default function Pay(props: PayProps) { const [open, setOpen] = useState(false) const [trade, setTrade] = useState(null) const router = useRouter() - // const platform = usePlatformType() const onOpen = async () => { setOpen(true) - if (props.method === 'balance') return + if (props.method === 'balance') { + return + } const method = props.method === 'alipay' ? TradeMethod.SftAlipay : TradeMethod.SftWechat - const req = { + const response = await prepareResource({ ...props.resource, payment_method: method, payment_platform: TradePlatform.Desktop, - } + }) - const resp = await prepareResource(req) - - if (!resp.success) { - toast.error(`创建订单失败: ${resp.message}`) + if (!response.success) { + toast.error(`创建订单失败: ${response.message}`) setOpen(false) return } setTrade({ - inner_no: resp.data.trade_no, - pay_url: resp.data.pay_url, + inner_no: response.data.trade_no, + pay_url: response.data.pay_url, amount: Number(props.amount), platform: TradePlatform.Desktop, - method: method, + method, }) } @@ -112,7 +110,6 @@ export default function Pay(props: PayProps) { 立即支付 - {/* 余额支付对话框 */} {props.method === 'balance' && ( @@ -178,7 +175,6 @@ export default function Pay(props: PayProps) { )} - {/* 支付宝/微信支付 */} {props.method !== 'balance' && trade && ( () + + return ( + + className="flex flex-col gap-4" + name="type" + label="计费方式" + > + {({id, field}) => ( + { + field.onChange(value) + + if (value === '2') { + setValue('expire', '0') + return + } + + if (props.expireList.length > 0) { + setValue('expire', props.expireList[0]) + } + + setValue('daily_limit', props.timeDailyLimit) + }} + className="flex gap-4 max-md:flex-col" + > + + + + + )} + + ) +} diff --git a/src/components/composites/purchase/shared/feature-list.tsx b/src/components/composites/purchase/shared/feature-list.tsx new file mode 100644 index 0000000..a4d9c83 --- /dev/null +++ b/src/components/composites/purchase/shared/feature-list.tsx @@ -0,0 +1,35 @@ +'use client' + +import Image from 'next/image' +import check from '../_assets/check.svg' + +const defaultFeatures = [ + '支持高并发提取', + '指定省份、城市或混播', + '账密+白名单验证', + '完备的API接口', + 'IP时效3-30分钟(可定制)', + 'IP资源定期筛选', + '包量/包时计费方式', + '每日去重量:500万', +] + +export function FeatureList(props: { + items?: string[] +}) { + const items = props.items || defaultFeatures + + return ( +
+

产品特性

+
+ {items.map(item => ( +

+ check + {item} +

+ ))} +
+
+ ) +} diff --git a/src/components/composites/purchase/shared/field-payment.tsx b/src/components/composites/purchase/shared/field-payment.tsx index c300c16..76cae74 100644 --- a/src/components/composites/purchase/shared/field-payment.tsx +++ b/src/components/composites/purchase/shared/field-payment.tsx @@ -6,20 +6,16 @@ import alipay from '../_assets/alipay.svg' import wechat from '../_assets/wechat.svg' import balance from '../_assets/balance.svg' import RechargeModal from '@/components/composites/recharge' -import {useProfileStore} from '@/components/stores/profile' -import {use} from 'react' -import Link from 'next/link' -import {buttonVariants} from '@/components/ui/button' -export function FieldPayment() { - const profile = use(useProfileStore(store => store.profile)) - - return profile ? ( +export function FieldPayment(props: { + balance: number +}) { + return ( {({id, field}) => ( @@ -29,7 +25,7 @@ export function FieldPayment() { 账户余额

- {profile.balance} + {props.balance}

@@ -60,9 +56,5 @@ export function FieldPayment() { )} - ) : ( - - 登录后支付 - ) } diff --git a/src/components/composites/purchase/shared/form-values.ts b/src/components/composites/purchase/shared/form-values.ts new file mode 100644 index 0000000..b652c65 --- /dev/null +++ b/src/components/composites/purchase/shared/form-values.ts @@ -0,0 +1,8 @@ +export type PurchaseFormValues = { + type: '1' | '2' + live: string + quota: number + expire: string + daily_limit: number + pay_type: 'wechat' | 'alipay' | 'balance' +} diff --git a/src/components/composites/purchase/shared/number-stepper-field.tsx b/src/components/composites/purchase/shared/number-stepper-field.tsx new file mode 100644 index 0000000..e3f390b --- /dev/null +++ b/src/components/composites/purchase/shared/number-stepper-field.tsx @@ -0,0 +1,73 @@ +'use client' + +import {useFormContext} from 'react-hook-form' +import {Minus, Plus} from 'lucide-react' +import {FormField} from '@/components/ui/form' +import {Button} from '@/components/ui/button' +import {Input} from '@/components/ui/input' +import {PurchaseFormValues} from './form-values' + +type PurchaseStepperFieldName = 'quota' | 'daily_limit' + +type NumberStepperFieldProps = { + name: PurchaseStepperFieldName + label: string + min: number + step: number +} + +export function NumberStepperField(props: NumberStepperFieldProps) { + const form = useFormContext() + + const setValue = (value: number) => { + form.setValue(props.name, value) + } + + return ( + + className="space-y-4" + name={props.name} + label={props.label} + > + {({id, field}) => { + const value = Number(field.value) || props.min + return ( +
+ + { + field.onBlur() + const nextValue = Number(event.target.value) + if (nextValue < props.min) { + setValue(props.min) + } + }} + /> + +
+ ) + }} + + ) +} diff --git a/src/components/composites/purchase/shared/resource.ts b/src/components/composites/purchase/shared/resource.ts new file mode 100644 index 0000000..71c2f9e --- /dev/null +++ b/src/components/composites/purchase/shared/resource.ts @@ -0,0 +1,38 @@ +import {CreateResourceReq} from '@/actions/resource' + +export type PurchaseKind = 'short' | 'long' +export type PurchaseMode = '1' | '2' + +export type PurchaseSelection = { + kind: PurchaseKind + mode: PurchaseMode + live: string + quota: number + expire: string + dailyLimit: number +} + +function getPurchasePayload(selection: PurchaseSelection) { + return { + mode: Number(selection.mode), + live: Number(selection.live), + expire: selection.mode === '1' ? Number(selection.expire) : undefined, + quota: selection.mode === '1' ? Number(selection.dailyLimit) : Number(selection.quota), + } +} + +export function buildPurchaseResource(selection: PurchaseSelection): CreateResourceReq { + const payload = getPurchasePayload(selection) + + if (selection.kind === 'short') { + return { + type: 1, + short: payload, + } + } + + return { + type: 2, + long: payload, + } +} diff --git a/src/components/composites/purchase/shared/side-panel.tsx b/src/components/composites/purchase/shared/side-panel.tsx new file mode 100644 index 0000000..dcba4c0 --- /dev/null +++ b/src/components/composites/purchase/shared/side-panel.tsx @@ -0,0 +1,189 @@ +'use client' + +import {use, useEffect, useRef, useState} from 'react' +import Link from 'next/link' +import {useFormContext, useWatch} from 'react-hook-form' +import {Card} from '@/components/ui/card' +import {buttonVariants} from '@/components/ui/button' +import {useProfileStore} from '@/components/stores/profile' +import Pay from '@/components/composites/purchase/pay' +import {FieldPayment} from './field-payment' +import {buildPurchaseResource, PurchaseKind, PurchaseSelection} from './resource' +import {getPrice, getPriceHome} from '@/actions/resource' +import {ExtraResp} from '@/lib/api' +import {formatPurchaseLiveLabel} from './sku' +import {User} from '@/lib/models' +import {PurchaseFormValues} from './form-values' + +const emptyPrice: ExtraResp = { + price: '0.00', + actual: '0.00', + discounted: '0.00', +} + +export type PurchaseSidePanelProps = { + kind: PurchaseKind +} + +export function PurchaseSidePanel(props: PurchaseSidePanelProps) { + const {control} = useFormContext() + const method = useWatch({control, name: 'pay_type'}) as PurchaseFormValues['pay_type'] + const mode = useWatch({control, name: 'type'}) as PurchaseFormValues['type'] + const live = useWatch({control, name: 'live'}) as PurchaseFormValues['live'] + const quota = useWatch({control, name: 'quota'}) as PurchaseFormValues['quota'] + const expire = useWatch({control, name: 'expire'}) as PurchaseFormValues['expire'] + const dailyLimit = useWatch({control, name: 'daily_limit'}) as PurchaseFormValues['daily_limit'] + const profile = use(useProfileStore(store => store.profile)) + const selection: PurchaseSelection = { + kind: props.kind, + mode, + live, + quota, + expire, + dailyLimit, + } + const priceData = usePurchasePrice(profile, selection) + const {price, actual: discountedPrice = '0.00'} = priceData + const totalDiscount = getTotalDiscount(price, discountedPrice) + const hasDiscount = Number(totalDiscount) > 0 + const liveLabel = formatPurchaseLiveLabel(live, props.kind) + const resource = buildPurchaseResource(selection) + + return ( + +

订单详情

+
    +
  • + 套餐名称 + {mode === '2' ? '包量套餐' : '包时套餐'} +
  • +
  • + IP 时效 + {liveLabel} +
  • + {mode === '2' ? ( + <> +
  • + 购买 IP 量 + {quota} 个 +
  • +
  • + 原价 + ¥{price} +
  • + {hasDiscount && ( +
  • + 总折扣 + -¥{totalDiscount} +
  • + )} + + ) : ( + <> +
  • + 套餐时长 + {expire} 天 +
  • +
  • + 每日限额 + {dailyLimit} 个 +
  • +
  • + 原价 + ¥{price} +
  • + {hasDiscount && ( +
  • + 总折扣 + -¥{totalDiscount} +
  • + )} + + )} +
+
+

+ 实付价格 + ¥{discountedPrice} +

+ {profile ? ( + <> + + + + ) : ( + + 登录后支付 + + )} +
+ ) +} + +function usePurchasePrice(profile: User | null, selection: PurchaseSelection) { + const [priceData, setPriceData] = useState>(emptyPrice) + const requestIdRef = useRef(0) + const {kind, mode, live, quota, expire, dailyLimit} = selection + + useEffect(() => { + const requestId = ++requestIdRef.current + + const loadPrice = async () => { + try { + const resource = buildPurchaseResource({ + kind, + mode, + live, + quota, + expire, + dailyLimit, + }) + const response = profile + ? await getPrice(resource) + : await getPriceHome(resource) + + if (requestId !== requestIdRef.current) { + return + } + + if (!response.success) { + throw new Error(response.message || '获取价格失败') + } + + setPriceData({ + price: response.data.price, + actual: response.data.actual ?? response.data.price ?? '0.00', + discounted: response.data.discounted ?? '0.00', + }) + } + catch (error) { + if (requestId !== requestIdRef.current) { + return + } + + console.error('获取价格失败:', error) + setPriceData(emptyPrice) + } + } + + loadPrice() + }, [dailyLimit, expire, kind, live, mode, profile, quota]) + + return priceData +} + +function getTotalDiscount(price: string, discountedPrice: string) { + const originalPrice = Number.parseFloat(price) + const actualPrice = Number.parseFloat(discountedPrice) + + if (Number.isNaN(originalPrice) || Number.isNaN(actualPrice)) { + return '0.00' + } + + return (originalPrice - actualPrice).toFixed(2) +} diff --git a/src/components/composites/purchase/shared/sku.ts b/src/components/composites/purchase/shared/sku.ts new file mode 100644 index 0000000..9b2ecfb --- /dev/null +++ b/src/components/composites/purchase/shared/sku.ts @@ -0,0 +1,72 @@ +import {ProductItem} from '@/actions/product' +import {PurchaseKind, PurchaseMode} from './resource' + +export type PurchaseSkuData = { + priceMap: Map + liveList: string[] + expireList: string[] +} + +export function parsePurchaseSkuList(kind: PurchaseKind, skuList: ProductItem['skus']): PurchaseSkuData { + if (!skuList?.length) { + throw new Error('没有套餐数据') + } + + const priceMap = new Map() + const liveSet = new Set() + const expireSet = new Set() + + for (const sku of skuList) { + const params = new URLSearchParams(sku.code) + const mode = params.get('mode') + const live = Number(params.get('live') || '0') + const expire = Number(params.get('expire') || '0') + + if (live > 0) { + liveSet.add(live) + } + + if (kind === 'short') { + if (mode === 'time' && expire > 0) { + expireSet.add(expire) + } + } + else if (expire > 0) { + expireSet.add(expire) + } + + priceMap.set(sku.code, sku.price) + } + + return { + priceMap, + liveList: Array.from(liveSet).sort((a, b) => a - b).map(String), + expireList: Array.from(expireSet).sort((a, b) => a - b).map(String), + } +} + +export function getPurchaseSkuPrice(priceMap: Map, props: { + mode: PurchaseMode + live: string + expire: string +}) { + const params = new URLSearchParams() + params.set('mode', props.mode === '1' ? 'time' : 'quota') + params.set('live', props.live || '0') + params.set('expire', props.mode === '1' ? props.expire || '0' : '0') + return priceMap.get(params.toString()) +} + +export function formatPurchaseLiveLabel(live: string, kind: PurchaseKind) { + const minutes = Number(live) + + if (kind === 'long') { + return `${minutes / 60} 小时` + } + + if (minutes % 60 === 0) { + return `${minutes / 60} 小时` + } + + return `${minutes} 分钟` +} diff --git a/src/components/composites/purchase/short/center.tsx b/src/components/composites/purchase/short/center.tsx index 20608ad..bdc36dc 100644 --- a/src/components/composites/purchase/short/center.tsx +++ b/src/components/composites/purchase/short/center.tsx @@ -1,15 +1,14 @@ 'use client' import {FormField} from '@/components/ui/form' import {RadioGroup} from '@/components/ui/radio-group' -import {Input} from '@/components/ui/input' -import {Button} from '@/components/ui/button' -import {Minus, Plus} from 'lucide-react' import FormOption from '@/components/composites/purchase/option' -import Image from 'next/image' -import check from '../_assets/check.svg' -import {useFormContext, useWatch} from 'react-hook-form' +import {useWatch} from 'react-hook-form' import {Schema} from '@/components/composites/purchase/short/form' import {Card} from '@/components/ui/card' +import {BillingMethodField} from '../shared/billing-method-field' +import {FeatureList} from '../shared/feature-list' +import {NumberStepperField} from '../shared/number-stepper-field' +import {getPurchaseSkuPrice} from '../shared/sku' export default function Center({ priceMap, @@ -20,52 +19,12 @@ export default function Center({ liveList: string[] expireList: string[] }) { - const form = useFormContext() - const type = useWatch({name: 'type'}) - const expire = useWatch({name: 'expire'}) - const isTime = type === '1' + const type = useWatch({name: 'type'}) as Schema['type'] + const expire = useWatch({name: 'expire'}) as Schema['expire'] return ( - - {/* 计费方式 */} - - className="flex flex-col gap-4" - name="type" - label="计费方式"> - {({id, field}) => ( - { - field.onChange(v) - if (v === '2') { - form.setValue('expire', '0') - } - else if (expireList.length > 0) { - form.setValue('expire', expireList[0]) - form.setValue('daily_limit', 2000) - } - }} - className="flex gap-4 max-md:flex-col"> - - - - - - - )} - + {/* IP 时效 */} @@ -79,11 +38,11 @@ export default function Center({ onValueChange={field.onChange} className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4"> {liveList.map((live) => { - const params = new URLSearchParams() - params.set('mode', isTime ? 'time' : 'quota') - params.set('live', live) - params.set('expire', isTime ? expire : '0') - const price = priceMap.get(params.toString()) + const price = getPurchaseSkuPrice(priceMap, { + mode: type, + live, + expire: String(expire), + }) const minutes = Number(live) const hours = minutes / 60 const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟` @@ -93,7 +52,7 @@ export default function Center({ id={`${id}-${live}`} value={live} label={label} - description={price && `¥${price}${!isTime ? '/IP' : ''}`} + description={price && `¥${price}/IP`} compare={field.value} /> ) @@ -102,146 +61,43 @@ export default function Center({ )} - {/* 根据套餐类型显示不同表单项 */} - {!isTime ? ( - /* 包量:IP 购买数量 */ - + {/* 套餐时效 */} + {type === '1' && ( + {({id, field}) => ( -
- - { - const value = Number(e.target.value) - if (value < 10000) { - form.setValue('quota', 10000) - } - }} - /> - -
+ + {expireList.map(day => ( + + ))} + )}
- ) : ( - <> - {/* 包时:套餐时效 */} - - {({id, field}) => ( - - {expireList.map(day => ( - - ))} - - )} - - - {/* 包时:每日提取上限 */} - - {({id, field}) => ( -
- - { - const value = Number(e.target.value) - if (value < 2000) { - form.setValue('daily_limit', 2000) - } - }} - /> - -
- )} -
- )} - {/* 产品特性 */} -
-

产品特性

-
-

- check - 支持高并发提取 -

-

- check - 指定省份、城市或混播 -

-

- check - 账密+白名单验证 -

-

- check - 完备的API接口 -

-

- check - IP时效3-30分钟(可定制) -

-

- check - IP资源定期筛选 -

-

- check - 包量/包时计费方式 -

-

- check - 每日去重量:500万 -

-
-
+ {/* 每日提取上限/购买数量 */} + {type === '1' ? ( + + ) : ( + + )} + +
) } diff --git a/src/components/composites/purchase/short/form.tsx b/src/components/composites/purchase/short/form.tsx index 9e5892d..5592950 100644 --- a/src/components/composites/purchase/short/form.tsx +++ b/src/components/composites/purchase/short/form.tsx @@ -1,13 +1,13 @@ 'use client' import {useForm} from 'react-hook-form' import Center from '@/components/composites/purchase/short/center' -import Right from '@/components/composites/purchase/short/right' import {Form} from '@/components/ui/form' import * as z from 'zod' import {zodResolver} from '@hookform/resolvers/zod' import {ProductItem} from '@/actions/product' +import {parsePurchaseSkuList} from '../shared/sku' +import {PurchaseSidePanel} from '../shared/side-panel' -// 定义表单验证架构 const schema = z.object({ type: z.enum(['1', '2']).default('2'), live: z.string(), @@ -16,33 +16,10 @@ const schema = z.object({ daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'), pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'), }) - export type Schema = z.infer export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) { - if (!skuList?.length) throw new Error('没有套餐数据') - - const priceMap = new Map() - const _liveList = new Set() - const _expireList = new Set() - - for (const sku of skuList) { - const params = new URLSearchParams(sku.code) - const mode = params.get('mode') - const live = params.get('live') - const expire = params.get('expire') - - if (live && live !== '0') { - _liveList.add(Number(live)) - } - if (mode === 'time' && expire && expire !== '0') { - _expireList.add(Number(expire)) - } - priceMap.set(sku.code, sku.price) - } - - const liveList = Array.from(_liveList).filter(Boolean).map(String) - const expireList = Array.from(_expireList).filter(Boolean).map(String) + const {priceMap, liveList, expireList} = parsePurchaseSkuList('short', skuList) const form = useForm({ resolver: zodResolver(schema), @@ -59,7 +36,7 @@ export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) { return (
- + ) } diff --git a/src/components/composites/purchase/short/right.tsx b/src/components/composites/purchase/short/right.tsx deleted file mode 100644 index d00b4c6..0000000 --- a/src/components/composites/purchase/short/right.tsx +++ /dev/null @@ -1,216 +0,0 @@ -'use client' -import {Suspense, use, useEffect, useState} from 'react' -import {Schema} from '@/components/composites/purchase/short/form' -import {useProfileStore} from '@/components/stores/profile' -import {buttonVariants} from '@/components/ui/button' -import Link from 'next/link' -import {merge} from '@/lib/utils' -import Pay from '@/components/composites/purchase/pay' -import {useFormContext, useWatch} from 'react-hook-form' -import {Card} from '@/components/ui/card' -import {getPrice, getPriceHome} from '@/actions/resource' -import {ExtraResp} from '@/lib/api' -import {FieldPayment} from '../shared/field-payment' -import {ProductItem} from '@/actions/product' - -export default function Right() { - const {control} = useFormContext() - const method = useWatch({control, name: 'pay_type'}) - const live = useWatch({control, name: 'live'}) - const mode = useWatch({control, name: 'type'}) - const expire = useWatch({control, name: 'expire'}) - const quota = useWatch({control, name: 'quota'}) - const dailyLimit = useWatch({control, name: 'daily_limit'}) - const [priceData, setPriceData] = useState>({ - price: '0.00', - actual: '0.00', - discounted: '0.00', - }) - const profile = use(useProfileStore(store => store.profile)) - - useEffect(() => { - const price = async () => { - try { - const priceResponse = profile - ? await getPrice({ - type: 1, - short: { - live: Number(live), - mode: Number(mode), - quota: mode === '1' ? Number(dailyLimit) : Number(quota), - expire: mode === '1' ? Number(expire) : undefined, - }, - }) - : await getPriceHome({ - type: 1, - short: { - live: Number(live), - mode: Number(mode), - quota: mode === '1' ? Number(dailyLimit) : Number(quota), - expire: mode === '1' ? Number(expire) : undefined, - }, - }) - - if (!priceResponse.success) { - throw new Error('获取价格失败') - } - - const data = priceResponse.data - setPriceData({ - price: data.price, - actual: data.actual ?? data.price ?? '', - discounted: data.discounted, - }) - } - catch (error) { - console.error('获取价格失败:', error) - setPriceData({ - price: '0.00', - actual: '0.00', - discounted: '0.00', - }) - } - } - price() - }, [expire, live, quota, mode, dailyLimit, profile]) - - const {price, actual: discountedPrice = ''} = priceData - - // 计算总折扣价(原价 - 实付价格) - const calculateTotalDiscount = () => { - const originalPrice = parseFloat(price) - const actualPrice = parseFloat(discountedPrice) - if (isNaN(originalPrice) || isNaN(actualPrice)) { - return '0.00' - } - const discount = originalPrice - actualPrice - return discount.toFixed(2) - } - - const totalDiscount = calculateTotalDiscount() - const hasDiscount = parseFloat(totalDiscount) > 0 - - return ( - -

订单详情

-
    -
  • - 套餐名称 - - {mode === '2' ? `包量套餐` : `包时套餐`} - -
  • -
  • - IP 时效 - - {live} - {' '} - 分钟 - -
  • - {mode === '2' ? ( - <> -
  • - 购买 IP 量 - - {quota} - 个 - -
  • -
  • - 原价 - - ¥{price} - -
  • - {hasDiscount && ( -
  • - 总折扣 - - -¥{totalDiscount} - -
  • - )} - - ) : ( - <> -
  • - 套餐时长 - - {expire} - 天 - -
  • -
  • - 每日限额 - - {dailyLimit} - 个 - -
  • -
  • - 原价 - - ¥{price} - -
  • - {hasDiscount && ( -
  • - 总折扣 - - -¥{totalDiscount} - -
  • - )} - - )} -
-
-

- 实付价格 - - ¥{discountedPrice} - -

- - - -
- ) -} - -function BalanceOrLogin(props: { - method: 'wechat' | 'alipay' | 'balance' - discountedPrice: string - mode: string - live: string - quota: number - expire: string - dailyLimit: number -}) { - const profile = use(useProfileStore(store => store.profile)) - return profile ? ( - <> - - - - ) : ( - - 登录后支付 - - ) -}