优化完善套餐购买页面
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import {useFormContext} from 'react-hook-form'
|
||||
import {FormField} from '@/components/ui/form'
|
||||
import {RadioGroup} from '@/components/ui/radio-group'
|
||||
import FormOption from '../option'
|
||||
import {PurchaseFormValues} from './form-values'
|
||||
|
||||
export function BillingMethodField(props: {
|
||||
expireList: string[]
|
||||
timeDailyLimit: number
|
||||
}) {
|
||||
const {setValue} = useFormContext<PurchaseFormValues>()
|
||||
|
||||
return (
|
||||
<FormField<PurchaseFormValues, 'type'>
|
||||
className="flex flex-col gap-4"
|
||||
name="type"
|
||||
label="计费方式"
|
||||
>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
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"
|
||||
>
|
||||
<FormOption
|
||||
id={`${id}-2`}
|
||||
value="2"
|
||||
label="包量套餐"
|
||||
description="适用于短期或不定期高提取业务场景"
|
||||
compare={field.value}
|
||||
/>
|
||||
|
||||
<FormOption
|
||||
id={`${id}-1`}
|
||||
value="1"
|
||||
label="包时套餐"
|
||||
description="适用于每日提取量稳定的业务场景"
|
||||
compare={field.value}
|
||||
/>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
35
src/components/composites/purchase/shared/feature-list.tsx
Normal file
35
src/components/composites/purchase/shared/feature-list.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<h3>产品特性</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6">
|
||||
{items.map(item => (
|
||||
<p key={item} className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">{item}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="flex flex-col gap-3">
|
||||
|
||||
@@ -29,7 +25,7 @@ export function FieldPayment() {
|
||||
<span className="text-sm text-gray-500">账户余额</span>
|
||||
</p>
|
||||
<p className="flex justify-between items-center">
|
||||
<span className="text-xl">{profile.balance}</span>
|
||||
<span className="text-xl">{props.balance}</span>
|
||||
<RechargeModal/>
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,9 +56,5 @@ export function FieldPayment() {
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
8
src/components/composites/purchase/shared/form-values.ts
Normal file
8
src/components/composites/purchase/shared/form-values.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type PurchaseFormValues = {
|
||||
type: '1' | '2'
|
||||
live: string
|
||||
quota: number
|
||||
expire: string
|
||||
daily_limit: number
|
||||
pay_type: 'wechat' | 'alipay' | 'balance'
|
||||
}
|
||||
@@ -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<PurchaseFormValues>()
|
||||
|
||||
const setValue = (value: number) => {
|
||||
form.setValue(props.name, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField<PurchaseFormValues, PurchaseStepperFieldName>
|
||||
className="space-y-4"
|
||||
name={props.name}
|
||||
label={props.label}
|
||||
>
|
||||
{({id, field}) => {
|
||||
const value = Number(field.value) || props.min
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => setValue(Math.max(props.min, value - props.step))}
|
||||
disabled={value === props.min}
|
||||
>
|
||||
<Minus/>
|
||||
</Button>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="number"
|
||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
onBlur={(event) => {
|
||||
field.onBlur()
|
||||
const nextValue = Number(event.target.value)
|
||||
if (nextValue < props.min) {
|
||||
setValue(props.min)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => setValue(value + props.step)}
|
||||
>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
38
src/components/composites/purchase/shared/resource.ts
Normal file
38
src/components/composites/purchase/shared/resource.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
189
src/components/composites/purchase/shared/side-panel.tsx
Normal file
189
src/components/composites/purchase/shared/side-panel.tsx
Normal file
@@ -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<typeof getPrice> = {
|
||||
price: '0.00',
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
}
|
||||
|
||||
export type PurchaseSidePanelProps = {
|
||||
kind: PurchaseKind
|
||||
}
|
||||
|
||||
export function PurchaseSidePanel(props: PurchaseSidePanelProps) {
|
||||
const {control} = useFormContext<PurchaseFormValues>()
|
||||
const method = useWatch<PurchaseFormValues>({control, name: 'pay_type'}) as PurchaseFormValues['pay_type']
|
||||
const mode = useWatch<PurchaseFormValues>({control, name: 'type'}) as PurchaseFormValues['type']
|
||||
const live = useWatch<PurchaseFormValues>({control, name: 'live'}) as PurchaseFormValues['live']
|
||||
const quota = useWatch<PurchaseFormValues>({control, name: 'quota'}) as PurchaseFormValues['quota']
|
||||
const expire = useWatch<PurchaseFormValues>({control, name: 'expire'}) as PurchaseFormValues['expire']
|
||||
const dailyLimit = useWatch<PurchaseFormValues>({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 (
|
||||
<Card className="flex-none basis-90 p-6 flex flex-col gap-6 relative">
|
||||
<h3>订单详情</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐名称</span>
|
||||
<span className="text-sm">{mode === '2' ? '包量套餐' : '包时套餐'}</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">IP 时效</span>
|
||||
<span className="text-sm">{liveLabel}</span>
|
||||
</li>
|
||||
{mode === '2' ? (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">购买 IP 量</span>
|
||||
<span className="text-sm">{quota} 个</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">¥{price}</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">-¥{totalDiscount}</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐时长</span>
|
||||
<span className="text-sm">{expire} 天</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">每日限额</span>
|
||||
<span className="text-sm">{dailyLimit} 个</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">¥{price}</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">-¥{totalDiscount}</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<div className="border-b border-gray-200"></div>
|
||||
<p className="flex justify-between items-center">
|
||||
<span>实付价格</span>
|
||||
<span className="text-xl text-orange-500">¥{discountedPrice}</span>
|
||||
</p>
|
||||
{profile ? (
|
||||
<>
|
||||
<FieldPayment balance={profile.balance}/>
|
||||
<Pay
|
||||
method={method}
|
||||
balance={profile.balance}
|
||||
amount={discountedPrice}
|
||||
resource={resource}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function usePurchasePrice(profile: User | null, selection: PurchaseSelection) {
|
||||
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>(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)
|
||||
}
|
||||
72
src/components/composites/purchase/shared/sku.ts
Normal file
72
src/components/composites/purchase/shared/sku.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {ProductItem} from '@/actions/product'
|
||||
import {PurchaseKind, PurchaseMode} from './resource'
|
||||
|
||||
export type PurchaseSkuData = {
|
||||
priceMap: Map<string, string>
|
||||
liveList: string[]
|
||||
expireList: string[]
|
||||
}
|
||||
|
||||
export function parsePurchaseSkuList(kind: PurchaseKind, skuList: ProductItem['skus']): PurchaseSkuData {
|
||||
if (!skuList?.length) {
|
||||
throw new Error('没有套餐数据')
|
||||
}
|
||||
|
||||
const priceMap = new Map<string, string>()
|
||||
const liveSet = new Set<number>()
|
||||
const expireSet = new Set<number>()
|
||||
|
||||
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<string, string>, 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} 分钟`
|
||||
}
|
||||
Reference in New Issue
Block a user