diff --git a/src/components/composites/purchase/long/center.tsx b/src/components/composites/purchase/long/center.tsx index 2d51d67..1073b7d 100644 --- a/src/components/composites/purchase/long/center.tsx +++ b/src/components/composites/purchase/long/center.tsx @@ -3,24 +3,67 @@ import {FormField} from '@/components/ui/form' import {RadioGroup} from '@/components/ui/radio-group' import FormOption from '@/components/composites/purchase/option' import {Schema} from '@/components/composites/purchase/long/form' -import {useWatch} from 'react-hook-form' +import {useEffect} from 'react' +import {useFormContext, 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' +import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku' -export default function Center({priceMap, expireList, liveList}: { - priceMap: Map - liveList: string[] - expireList: string[] +export default function Center({skuData}: { + skuData: PurchaseSkuData }) { + const {setValue} = useFormContext() const type = useWatch({name: 'type'}) as Schema['type'] + const live = useWatch({name: 'live'}) as Schema['live'] const expire = useWatch({name: 'expire'}) as Schema['expire'] + const {modeList, priceMap} = skuData + const liveList = type === '1' + ? getAvailablePurchaseLives(skuData, {mode: type, expire}) + : getAvailablePurchaseLives(skuData, {mode: type}) + const expireList = type === '1' + ? getAvailablePurchaseExpires(skuData, {mode: type, live}) + : [] + + useEffect(() => { + const nextType = modeList.includes(type) ? type : modeList[0] + + if (!nextType) { + return + } + + if (nextType !== type) { + setValue('type', nextType) + return + } + + const nextLiveList = nextType === '1' + ? getAvailablePurchaseLives(skuData, {mode: nextType, expire}) + : getAvailablePurchaseLives(skuData, {mode: nextType}) + const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0] + + if (nextLive && nextLive !== live) { + setValue('live', nextLive) + return + } + + if (nextType === '2') { + if (expire !== '0') { + setValue('expire', '0') + } + return + } + + const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive}) + if (!nextExpireList.includes(expire) && nextExpireList[0]) { + setValue('expire', nextExpireList[0]) + } + }, [expire, live, modeList, setValue, skuData, type]) return ( - + {/* IP 时效 */} @@ -31,13 +74,27 @@ export default function Center({priceMap, expireList, liveList}: { { + field.onChange(value) + + if (type !== '1') { + return + } + + const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value}) + if (!nextExpireList.includes(expire) && nextExpireList[0]) { + setValue('expire', nextExpireList[0]) + } + }} className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4"> {liveList.map((live) => { + const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire}) + ? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0' + : String(expire) const price = getPurchaseSkuPrice(priceMap, { mode: type, live, - expire: String(expire), + expire: priceExpire, }) return ( {({id, field}) => ( - + { + field.onChange(value) + + const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value}) + if (!nextLiveList.includes(live) && nextLiveList[0]) { + setValue('live', nextLiveList[0]) + } + }} + className="flex gap-4 flex-wrap"> {expireList.map(day => ( export default function LongForm({skuList}: {skuList: ProductItem['skus']}) { - const {priceMap, liveList, expireList} = parsePurchaseSkuList('long', skuList) + const skuData = parsePurchaseSkuList('long', skuList) + const defaultMode = skuData.modeList.includes('2') ? '2' : '1' + const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || '' + const defaultExpire = defaultMode === '1' + ? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0' + : '0' const form = useForm({ resolver: zodResolver(schema), defaultValues: { - type: '2', // 默认为包量套餐 - live: liveList[0], // 分钟 - expire: '0', // 天 + type: defaultMode, + live: defaultLive, + expire: defaultExpire, quota: 500, daily_limit: 100, pay_type: 'balance', // 余额支付 @@ -35,7 +40,7 @@ export default function LongForm({skuList}: {skuList: ProductItem['skus']}) { return (
-
+
) diff --git a/src/components/composites/purchase/shared/billing-method-field.tsx b/src/components/composites/purchase/shared/billing-method-field.tsx index 86e4537..89e3d1c 100644 --- a/src/components/composites/purchase/shared/billing-method-field.tsx +++ b/src/components/composites/purchase/shared/billing-method-field.tsx @@ -4,10 +4,11 @@ import {useFormContext} from 'react-hook-form' import {FormField} from '@/components/ui/form' import {RadioGroup} from '@/components/ui/radio-group' import FormOption from '../option' +import {PurchaseMode} from './resource' import {PurchaseFormValues} from './form-values' export function BillingMethodField(props: { - expireList: string[] + modeList: PurchaseMode[] timeDailyLimit: number }) { const {setValue} = useFormContext() @@ -30,29 +31,29 @@ export function BillingMethodField(props: { return } - if (props.expireList.length > 0) { - setValue('expire', props.expireList[0]) - } - setValue('daily_limit', props.timeDailyLimit) }} className="flex gap-4 max-md:flex-col" > - + {props.modeList.includes('2') && ( + + )} - + {props.modeList.includes('1') && ( + + )} )} diff --git a/src/components/composites/purchase/shared/sku.ts b/src/components/composites/purchase/shared/sku.ts index 9b2ecfb..0240b6e 100644 --- a/src/components/composites/purchase/shared/sku.ts +++ b/src/components/composites/purchase/shared/sku.ts @@ -1,8 +1,18 @@ import {ProductItem} from '@/actions/product' import {PurchaseKind, PurchaseMode} from './resource' +export type PurchaseSkuItem = { + code: string + mode: PurchaseMode + live: string + expire: string + price: string +} + export type PurchaseSkuData = { + items: PurchaseSkuItem[] priceMap: Map + modeList: PurchaseMode[] liveList: string[] expireList: string[] } @@ -12,40 +22,82 @@ export function parsePurchaseSkuList(kind: PurchaseKind, skuList: ProductItem['s throw new Error('没有套餐数据') } + const items: PurchaseSkuItem[] = [] const priceMap = new Map() + const modeSet = new Set() 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 mode = parsePurchaseSkuMode(params.get('mode')) const live = Number(params.get('live') || '0') const expire = Number(params.get('expire') || '0') - if (live > 0) { - liveSet.add(live) + if (!mode || live <= 0) { + continue } + const liveValue = String(live) + const expireValue = mode === '1' ? String(expire || '0') : '0' + const code = getPurchaseSkuKey({ + mode, + live: liveValue, + expire: expireValue, + }) + + items.push({ + code, + mode, + live: liveValue, + expire: expireValue, + price: sku.price, + }) + priceMap.set(code, sku.price) + modeSet.add(mode) + + liveSet.add(live) + if (kind === 'short') { - if (mode === 'time' && expire > 0) { + if (mode === '1' && expire > 0) { expireSet.add(expire) } } else if (expire > 0) { expireSet.add(expire) } + } - priceMap.set(sku.code, sku.price) + if (items.length === 0) { + throw new Error('没有可用的套餐数据') } return { + items, priceMap, - liveList: Array.from(liveSet).sort((a, b) => a - b).map(String), - expireList: Array.from(expireSet).sort((a, b) => a - b).map(String), + modeList: (['2', '1'] as const).filter(mode => modeSet.has(mode)), + liveList: sortNumericValues(liveSet), + expireList: sortNumericValues(expireSet), } } -export function getPurchaseSkuPrice(priceMap: Map, props: { +function parsePurchaseSkuMode(mode: string | null): PurchaseMode | null { + if (mode === 'time') { + return '1' + } + + if (mode === 'quota') { + return '2' + } + + return null +} + +function sortNumericValues(values: Iterable) { + return Array.from(values).sort((a, b) => a - b).map(String) +} + +export function getPurchaseSkuKey(props: { mode: PurchaseMode live: string expire: string @@ -54,7 +106,48 @@ export function getPurchaseSkuPrice(priceMap: Map, props: { 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()) + return params.toString() +} + +export function getAvailablePurchaseLives(skuData: PurchaseSkuData, props: { + mode: PurchaseMode + expire?: string +}) { + return sortNumericValues(new Set( + skuData.items + .filter(item => item.mode === props.mode) + .filter(item => !props.expire || item.expire === props.expire) + .map(item => Number(item.live)), + )) +} + +export function getAvailablePurchaseExpires(skuData: PurchaseSkuData, props: { + mode: PurchaseMode + live?: string +}) { + return sortNumericValues(new Set( + skuData.items + .filter(item => item.mode === props.mode) + .filter(item => !props.live || item.live === props.live) + .filter(item => item.expire !== '0') + .map(item => Number(item.expire)), + )) +} + +export function hasPurchaseSku(skuData: PurchaseSkuData, props: { + mode: PurchaseMode + live: string + expire: string +}) { + return skuData.priceMap.has(getPurchaseSkuKey(props)) +} + +export function getPurchaseSkuPrice(priceMap: Map, props: { + mode: PurchaseMode + live: string + expire: string +}) { + return priceMap.get(getPurchaseSkuKey(props)) } export function formatPurchaseLiveLabel(live: string, kind: PurchaseKind) { diff --git a/src/components/composites/purchase/short/center.tsx b/src/components/composites/purchase/short/center.tsx index bdc36dc..1b5533c 100644 --- a/src/components/composites/purchase/short/center.tsx +++ b/src/components/composites/purchase/short/center.tsx @@ -2,29 +2,70 @@ import {FormField} from '@/components/ui/form' import {RadioGroup} from '@/components/ui/radio-group' import FormOption from '@/components/composites/purchase/option' -import {useWatch} from 'react-hook-form' +import {useEffect} from 'react' +import {useFormContext, 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' +import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku' export default function Center({ - priceMap, - liveList, - expireList, + skuData, }: { - priceMap: Map - liveList: string[] - expireList: string[] + skuData: PurchaseSkuData }) { + const {setValue} = useFormContext() const type = useWatch({name: 'type'}) as Schema['type'] + const live = useWatch({name: 'live'}) as Schema['live'] const expire = useWatch({name: 'expire'}) as Schema['expire'] + const {modeList, priceMap} = skuData + const liveList = type === '1' + ? getAvailablePurchaseLives(skuData, {mode: type, expire}) + : getAvailablePurchaseLives(skuData, {mode: type}) + const expireList = type === '1' + ? getAvailablePurchaseExpires(skuData, {mode: type, live}) + : [] + + useEffect(() => { + const nextType = modeList.includes(type) ? type : modeList[0] + + if (!nextType) { + return + } + + if (nextType !== type) { + setValue('type', nextType) + return + } + + const nextLiveList = nextType === '1' + ? getAvailablePurchaseLives(skuData, {mode: nextType, expire}) + : getAvailablePurchaseLives(skuData, {mode: nextType}) + const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0] + + if (nextLive && nextLive !== live) { + setValue('live', nextLive) + return + } + + if (nextType === '2') { + if (expire !== '0') { + setValue('expire', '0') + } + return + } + + const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive}) + if (!nextExpireList.includes(expire) && nextExpireList[0]) { + setValue('expire', nextExpireList[0]) + } + }, [expire, live, modeList, setValue, skuData, type]) return ( - + {/* IP 时效 */} @@ -35,13 +76,27 @@ export default function Center({ { + field.onChange(value) + + if (type !== '1') { + return + } + + const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value}) + if (!nextExpireList.includes(expire) && nextExpireList[0]) { + setValue('expire', nextExpireList[0]) + } + }} className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4"> {liveList.map((live) => { + const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire}) + ? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0' + : String(expire) const price = getPurchaseSkuPrice(priceMap, { mode: type, live, - expire: String(expire), + expire: priceExpire, }) const minutes = Number(live) const hours = minutes / 60 @@ -65,7 +120,18 @@ export default function Center({ {type === '1' && ( {({id, field}) => ( - + { + field.onChange(value) + + const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value}) + if (!nextLiveList.includes(live) && nextLiveList[0]) { + setValue('live', nextLiveList[0]) + } + }} + className="flex gap-4 flex-wrap"> {expireList.map(day => ( export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) { - const {priceMap, liveList, expireList} = parsePurchaseSkuList('short', skuList) + const skuData = parsePurchaseSkuList('short', skuList) + const defaultMode = skuData.modeList.includes('2') ? '2' : '1' + const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || '' + const defaultExpire = defaultMode === '1' + ? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0' + : '0' const form = useForm({ resolver: zodResolver(schema), defaultValues: { - type: '2', // 默认为包量套餐 - live: liveList[0] || '', - expire: '0', // 包量模式下无效 + type: defaultMode, + live: defaultLive, + expire: defaultExpire, quota: 10_000, // >= 10000, daily_limit: 2_000, // >= 2000 pay_type: 'balance', // 余额支付 @@ -35,7 +40,7 @@ export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) { return (
-
+
)