From d4de1370e83a8e24c68fbb14afc5197bd74cc231 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 2 Jul 2026 14:39:09 +0800 Subject: [PATCH] feat(payment): implement first recharge offer functionality and UI updates --- docs/backend/plans.md | 64 ++++----- .../subscription-screen.helpers.test.ts | 51 +++++++ ...ubscription-coins-offer-section.module.css | 42 +++++- .../subscription-coins-offer-section.tsx | 23 ++- .../components/subscription-screen.module.css | 46 ++++++ .../subscription-vip-offer-section.module.css | 21 ++- .../subscription-vip-offer-section.tsx | 12 +- .../subscription-screen.helpers.ts | 35 +++++ src/app/subscription/subscription-screen.tsx | 35 +++++ .../payment/__tests__/payment_plan.test.ts | 41 ++++++ src/data/dto/payment/payment_plan.ts | 3 + .../dto/payment/payment_plans_response.ts | 9 ++ .../payment-plans-first-recharge.json | 135 ++++++++++++++++++ src/data/schemas/payment/payment_plan.ts | 3 + .../schemas/payment/payment_plans_response.ts | 9 ++ .../payment/__tests__/payment-machine.test.ts | 46 ++++++ src/stores/payment/payment-context.tsx | 4 + src/stores/payment/payment-machine.helpers.ts | 50 ++++++- src/stores/payment/payment-machine.ts | 23 +-- src/stores/payment/payment-state.ts | 5 + 20 files changed, 607 insertions(+), 50 deletions(-) create mode 100644 src/data/mock/payment/responses/payment-plans-first-recharge.json diff --git a/docs/backend/plans.md b/docs/backend/plans.md index ceff63b3..f3916062 100644 --- a/docs/backend/plans.md +++ b/docs/backend/plans.md @@ -1,34 +1,34 @@ -GET https://proapi.banlv-ai.com/api/payment/plans -结果: -status=200 -success=true -plans 数量 8 -VIP 套餐 3 个 -credits 套餐 5 个 -snake_keys=[],已经没有 plan_id / amount_cents 这种字段 -schema 校验通过:proapi_plans_camel_schema_ok true -前端按这个结构用: -type PaymentPlan = { - planId: string; - planName: string; - orderType: 'vip_monthly' | 'vip_quarterly' | 'vip_annual' | 'dol'; - vipDays: number | null; - dolAmount: number | null; - creditBalance: number; - amountCents: number; - originalAmountCents: number | null; - dailyPriceCents: number | null; - currency: string; -}; -分组: -const plans = res.data.plans; - -const vipPlans = plans.filter(p => p.vipDays !== null); -const creditPlans = plans.filter(p => p.dolAmount !== null); -下单仍然传 planId: +有更新,主要是 /api/payment/plans。 +前端判断方式: +调用: +GET /api/payment/plans +如果用户已登录,请带上: +Authorization: Bearer +后端会返回: { - "planId": "vip_monthly", - "payChannel": "stripe", - "autoRenew": true + "isFirstRecharge": true, + "firstRechargeOffer": { + "enabled": true, + "type": "first_recharge_half_price", + "discountPercent": 50 + }, + "plans": [ + { + "planId": "vip_monthly", + "amountCents": 995, + "originalAmountCents": 1990, + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + } + ] } -价格、币种全部以前端拿到的 amountCents / currency 展示,不要写死。当前我这边访问 pro 返回的是 JPY,这是 Cloudflare 根据访问 IP 判的国家币种。 \ No newline at end of file +前端逻辑很简单: +data.isFirstRecharge === true 或 plan.isFirstRechargeOffer === true:展示首充价 +amountCents:实际支付价,也就是首充半价 +originalAmountCents:划线原价 +firstRechargeDiscountPercent:折扣,比如 50 +不需要前端自己算半价 +创建订单还是原接口: +POST /api/payment/create-order +前端只传原来的 planId/payChannel/autoRenew,不用传首充字段。后端会重新判断首充资格,并按半价创建订单,防止前端伪造价格。 \ No newline at end of file diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts index 93ef745e..f5d64127 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -6,6 +6,7 @@ import { findSelectedSubscriptionPlan, getDefaultPayChannelForCountryCode, getDefaultSubscriptionPlanId, + getFirstRechargeOfferView, toCoinsOfferPlanViews, toVipOfferPlanViews, } from "../subscription-screen.helpers"; @@ -103,6 +104,56 @@ describe("subscription screen helpers", () => { ]); }); + it("maps first recharge offer fields to plan views", () => { + const vipViews = toVipOfferPlanViews([ + makePlan({ + planId: "vip_monthly", + amountCents: 995, + originalAmountCents: 1990, + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + promotionType: "first_recharge_half_price", + }), + ]); + const coinViews = toCoinsOfferPlanViews([ + makePlan({ + planId: "coin_1000", + orderType: "coins_1000", + vipDays: null, + dolAmount: 1000, + amountCents: 495, + originalAmountCents: 990, + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + promotionType: "first_recharge_half_price", + }), + ]); + + expect(vipViews[0]).toMatchObject({ + price: "9.95", + originalPrice: "19.9", + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + }); + expect(coinViews[0]).toMatchObject({ + price: "4.95", + originalPrice: "9.9", + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + }); + expect( + getFirstRechargeOfferView({ + isFirstRecharge: true, + discountPercent: 50, + vipPlans: vipViews, + coinPlans: coinViews, + }), + ).toMatchObject({ + badgeText: "50% OFF", + title: "New User First Recharge", + }); + }); + it("selects the first VIP plan by default when VIP can be purchased", () => { expect( getDefaultSubscriptionPlanId({ diff --git a/src/app/subscription/components/subscription-coins-offer-section.module.css b/src/app/subscription/components/subscription-coins-offer-section.module.css index 9933207d..cac34974 100644 --- a/src/app/subscription/components/subscription-coins-offer-section.module.css +++ b/src/app/subscription/components/subscription-coins-offer-section.module.css @@ -45,7 +45,7 @@ .row { display: grid; - grid-template-columns: 48px minmax(0, 1fr) auto; + grid-template-columns: 48px minmax(0, 1fr) auto auto; align-items: center; width: 100%; min-height: 48px; @@ -102,6 +102,28 @@ line-height: 1.2; } +.offerBadge { + justify-self: end; + padding: 4px 8px; + border-radius: 999px; + background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%); + color: #ffffff; + font-size: 11px; + font-weight: 900; + line-height: 1; + white-space: nowrap; + box-shadow: 0 7px 16px rgba(255, 95, 174, 0.24); +} + +.priceGroup { + display: inline-flex; + min-width: 54px; + flex-direction: column; + align-items: flex-end; + justify-content: center; + line-height: 1; +} + .price { font-size: 16px; font-weight: 800; @@ -109,6 +131,16 @@ white-space: nowrap; } +.originalPrice { + margin-top: 3px; + color: #9c8b91; + font-size: 12px; + font-weight: 800; + line-height: 1; + text-decoration: line-through; + white-space: nowrap; +} + @media (max-width: 360px) { .title { font-size: 20px; @@ -120,11 +152,17 @@ } .row { - grid-template-columns: 46px minmax(0, 1fr) auto; + grid-template-columns: 46px minmax(0, 1fr) auto auto; } .coins, .price { font-size: 14px; } + + .offerBadge { + padding-right: 6px; + padding-left: 6px; + font-size: 10px; + } } diff --git a/src/app/subscription/components/subscription-coins-offer-section.tsx b/src/app/subscription/components/subscription-coins-offer-section.tsx index f132890f..058bb286 100644 --- a/src/app/subscription/components/subscription-coins-offer-section.tsx +++ b/src/app/subscription/components/subscription-coins-offer-section.tsx @@ -9,6 +9,10 @@ export interface CoinsOfferPlanView { coins: number; price: string; currency: string; + originalPrice?: string; + isFirstRechargeOffer?: boolean; + firstRechargeDiscountPercent?: number | null; + promotionType?: string | null; } export interface SubscriptionCoinsOfferSectionProps { @@ -45,9 +49,22 @@ export function SubscriptionCoinsOfferSection({ {plan.coins} Coins - - {plan.currency} - {plan.price} + {plan.isFirstRechargeOffer ? ( + + {plan.firstRechargeDiscountPercent ?? 50}% OFF + + ) : null} + + + {plan.currency} + {plan.price} + + {plan.originalPrice ? ( + + {plan.currency} + {plan.originalPrice} + + ) : null} ); diff --git a/src/app/subscription/components/subscription-screen.module.css b/src/app/subscription/components/subscription-screen.module.css index e2f3cae5..0d0b4f05 100644 --- a/src/app/subscription/components/subscription-screen.module.css +++ b/src/app/subscription/components/subscription-screen.module.css @@ -18,6 +18,52 @@ display: inline-flex; } +.firstRechargeBanner { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + padding: 12px 14px; + border: 1px solid rgba(255, 95, 174, 0.28); + border-radius: 22px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 239, 248, 0.94) 100%), + #ffffff; + box-shadow: 0 14px 32px rgba(255, 95, 174, 0.16); +} + +.firstRechargeBadge { + flex: 0 0 auto; + padding: 8px 10px; + border-radius: 999px; + background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%); + color: #ffffff; + font-size: 13px; + font-weight: 900; + line-height: 1; + box-shadow: 0 10px 22px rgba(255, 95, 174, 0.25); +} + +.firstRechargeCopy { + min-width: 0; +} + +.firstRechargeTitle { + margin: 0; + color: #181014; + font-size: 17px; + font-weight: 900; + line-height: 1.15; +} + +.firstRechargeSubtitle { + margin: 4px 0 0; + color: #75656d; + font-size: 12px; + font-weight: 700; + line-height: 1.25; +} + .offerStack { display: flex; flex-direction: column; diff --git a/src/app/subscription/components/subscription-vip-offer-section.module.css b/src/app/subscription/components/subscription-vip-offer-section.module.css index 00960e1c..e073a0bf 100644 --- a/src/app/subscription/components/subscription-vip-offer-section.module.css +++ b/src/app/subscription/components/subscription-vip-offer-section.module.css @@ -63,9 +63,10 @@ } .planCard { + position: relative; display: flex; min-width: 0; - min-height: 118px; + min-height: 128px; flex-direction: column; align-items: center; justify-content: center; @@ -101,7 +102,25 @@ transform: translateY(-3px); } +.offerBadge { + position: absolute; + top: 8px; + left: 50%; + max-width: calc(100% - 12px); + padding: 3px 7px; + border-radius: 999px; + background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%); + color: #ffffff; + font-size: 10px; + font-weight: 900; + line-height: 1; + white-space: nowrap; + box-shadow: 0 7px 16px rgba(255, 95, 174, 0.28); + transform: translateX(-50%); +} + .planTitle { + margin-top: 12px; font-size: 19px; font-weight: 800; line-height: 1.15; diff --git a/src/app/subscription/components/subscription-vip-offer-section.tsx b/src/app/subscription/components/subscription-vip-offer-section.tsx index 5b04b820..c21d0fea 100644 --- a/src/app/subscription/components/subscription-vip-offer-section.tsx +++ b/src/app/subscription/components/subscription-vip-offer-section.tsx @@ -8,6 +8,9 @@ export interface VipOfferPlanView { price: string; currency: string; originalPrice: string; + isFirstRechargeOffer?: boolean; + firstRechargeDiscountPercent?: number | null; + promotionType?: string | null; } export interface SubscriptionVipOfferSectionProps { @@ -48,12 +51,19 @@ export function SubscriptionVipOfferSection({ aria-label={`${plan.title}, ${plan.price} ${plan.currency}`} onClick={() => onSelectPlan(plan.id)} > + {plan.isFirstRechargeOffer ? ( + + First recharge {plan.firstRechargeDiscountPercent ?? 50}% OFF + + ) : null} {plan.title} {plan.price} {plan.currency} - {plan.originalPrice} + + {plan.originalPrice || "\u00a0"} + ); })} diff --git a/src/app/subscription/subscription-screen.helpers.ts b/src/app/subscription/subscription-screen.helpers.ts index 17a6f0cf..090f6130 100644 --- a/src/app/subscription/subscription-screen.helpers.ts +++ b/src/app/subscription/subscription-screen.helpers.ts @@ -6,6 +6,12 @@ import type { VipOfferPlanView } from "./components/subscription-vip-offer-secti export type SubscriptionType = "vip" | "topup"; +export interface FirstRechargeOfferView { + badgeText: string; + title: string; + subtitle: string; +} + export function isVipPlan(plan: PaymentPlan): boolean { return plan.vipDays !== null; } @@ -54,6 +60,9 @@ export function toVipOfferPlanView(plan: PaymentPlan): VipOfferPlanView { price: formatOfferAmount(plan.amountCents), currency: formatOfferCurrency(plan.currency), originalPrice: formatOfferAmount(plan.originalAmountCents), + isFirstRechargeOffer: plan.isFirstRechargeOffer, + firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent, + promotionType: plan.promotionType, }; } @@ -63,6 +72,10 @@ export function toCoinsOfferPlanView(plan: PaymentPlan): CoinsOfferPlanView { coins: plan.dolAmount ?? 0, price: formatOfferAmount(plan.amountCents), currency: formatCoinCurrency(plan.currency), + originalPrice: formatOfferAmount(plan.originalAmountCents), + isFirstRechargeOffer: plan.isFirstRechargeOffer, + firstRechargeDiscountPercent: plan.firstRechargeDiscountPercent, + promotionType: plan.promotionType, }; } @@ -123,3 +136,25 @@ export function getDefaultSubscriptionPlanId(input: { if (input.selectedPlan !== null) return null; return input.coinPlans[0]?.id ?? null; } + +export function getFirstRechargeOfferView(input: { + isFirstRecharge: boolean; + discountPercent?: number | null; + vipPlans: readonly VipOfferPlanView[]; + coinPlans: readonly CoinsOfferPlanView[]; +}): FirstRechargeOfferView | null { + const promotedPlan = [...input.vipPlans, ...input.coinPlans].find( + (plan) => plan.isFirstRechargeOffer, + ); + if (!input.isFirstRecharge && !promotedPlan) return null; + + const discountPercent = + promotedPlan?.firstRechargeDiscountPercent ?? input.discountPercent ?? 50; + const badgeText = `${discountPercent}% OFF`; + return { + badgeText, + title: "New User First Recharge", + subtitle: + "Your first recharge price is already applied. No code needed.", + }; +} diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index ee875b20..f39b3a66 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -16,6 +16,7 @@ import { import styles from "./components/subscription-screen.module.css"; import { findSelectedSubscriptionPlan, + getFirstRechargeOfferView, getDefaultSubscriptionPlanId, toCoinsOfferPlanViews, toVipOfferPlanViews, @@ -60,6 +61,21 @@ export function SubscriptionScreen({ () => toCoinsOfferPlanViews(payment.plans), [payment.plans], ); + const firstRechargeOffer = useMemo( + () => + getFirstRechargeOfferView({ + isFirstRecharge: payment.isFirstRecharge, + discountPercent: payment.firstRechargeOffer?.discountPercent, + vipPlans: vipOfferPlans, + coinPlans: directCoinsPlans, + }), + [ + directCoinsPlans, + payment.firstRechargeOffer?.discountPercent, + payment.isFirstRecharge, + vipOfferPlans, + ], + ); const selectedPlan = findSelectedSubscriptionPlan({ canSubscribeVip, @@ -111,6 +127,25 @@ export function SubscriptionScreen({ /> + {firstRechargeOffer ? ( +
+ + {firstRechargeOffer.badgeText} + +
+

+ {firstRechargeOffer.title} +

+

+ {firstRechargeOffer.subtitle} +

+
+
+ ) : null} +
{canSubscribeVip ? ( { originalAmountCents: 2499, dailyPriceCents: 66, currency: "JPY", + isFirstRechargeOffer: false, + firstRechargeDiscountPercent: null, + promotionType: null, }); }); @@ -73,4 +76,42 @@ describe("PaymentPlansResponse", () => { expect(response.plans).toHaveLength(1); expect(response.plans[0]?.dolAmount).toBe(1000); }); + + it("parses first recharge offer metadata", () => { + const response = PaymentPlansResponse.from({ + isFirstRecharge: true, + firstRechargeOffer: { + enabled: true, + type: "first_recharge_half_price", + discountPercent: 50, + }, + plans: [ + { + planId: "vip_monthly", + planName: "VIP Monthly", + orderType: "vip_monthly", + vipDays: 30, + dolAmount: null, + creditBalance: 3000, + amountCents: 995, + originalAmountCents: 1990, + dailyPriceCents: null, + currency: "USD", + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + promotionType: "first_recharge_half_price", + }, + ], + }); + + expect(response.isFirstRecharge).toBe(true); + expect(response.firstRechargeOffer?.discountPercent).toBe(50); + expect(response.plans[0]).toMatchObject({ + amountCents: 995, + originalAmountCents: 1990, + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + promotionType: "first_recharge_half_price", + }); + }); }); diff --git a/src/data/dto/payment/payment_plan.ts b/src/data/dto/payment/payment_plan.ts index 84ee0871..9d5e390e 100644 --- a/src/data/dto/payment/payment_plan.ts +++ b/src/data/dto/payment/payment_plan.ts @@ -18,6 +18,9 @@ export class PaymentPlan { declare readonly originalAmountCents: number | null; declare readonly dailyPriceCents: number | null; declare readonly currency: string; + declare readonly isFirstRechargeOffer: boolean; + declare readonly firstRechargeDiscountPercent: number | null; + declare readonly promotionType: string | null; private constructor(input: PaymentPlanInput) { const data = PaymentPlanSchema.parse(input); diff --git a/src/data/dto/payment/payment_plans_response.ts b/src/data/dto/payment/payment_plans_response.ts index 03390e1d..ed14250a 100644 --- a/src/data/dto/payment/payment_plans_response.ts +++ b/src/data/dto/payment/payment_plans_response.ts @@ -3,6 +3,7 @@ */ import { PaymentPlansResponseSchema, + type FirstRechargeOfferData, type PaymentPlansResponseData, type PaymentPlansResponseInput, } from "@/data/schemas/payment/payment_plans_response"; @@ -10,11 +11,15 @@ import { import { PaymentPlan } from "./payment_plan"; export class PaymentPlansResponse { + declare readonly isFirstRecharge: boolean; + declare readonly firstRechargeOffer: FirstRechargeOfferData | null; declare readonly plans: PaymentPlan[]; private constructor(input: PaymentPlansResponseInput) { const data = PaymentPlansResponseSchema.parse(input); Object.assign(this, { + isFirstRecharge: data.isFirstRecharge, + firstRechargeOffer: data.firstRechargeOffer, plans: data.plans.map((plan) => PaymentPlan.from(plan)), }); Object.freeze(this); @@ -30,7 +35,11 @@ export class PaymentPlansResponse { toJson(): PaymentPlansResponseData { return { + isFirstRecharge: this.isFirstRecharge, + firstRechargeOffer: this.firstRechargeOffer, plans: this.plans.map((plan) => plan.toJson()), }; } } + +export type FirstRechargeOffer = FirstRechargeOfferData; diff --git a/src/data/mock/payment/responses/payment-plans-first-recharge.json b/src/data/mock/payment/responses/payment-plans-first-recharge.json new file mode 100644 index 00000000..2867da9d --- /dev/null +++ b/src/data/mock/payment/responses/payment-plans-first-recharge.json @@ -0,0 +1,135 @@ +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "isFirstRecharge": true, + "firstRechargeOffer": { + "enabled": true, + "type": "first_recharge_half_price", + "discountPercent": 50 + }, + "plans": [ + { + "planId": "vip_monthly", + "planName": "VIP Monthly", + "orderType": "vip_monthly", + "vipDays": 30, + "dolAmount": null, + "creditBalance": 3000, + "amountCents": 995, + "originalAmountCents": 1990, + "dailyPriceCents": 33, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + }, + { + "planId": "vip_quarterly", + "planName": "VIP Quarterly", + "orderType": "vip_quarterly", + "vipDays": 90, + "dolAmount": null, + "creditBalance": 9000, + "amountCents": 2495, + "originalAmountCents": 4990, + "dailyPriceCents": 28, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + }, + { + "planId": "vip_annual", + "planName": "VIP Annual", + "orderType": "vip_annual", + "vipDays": 365, + "dolAmount": null, + "creditBalance": 36000, + "amountCents": 8995, + "originalAmountCents": 17990, + "dailyPriceCents": 25, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + }, + { + "planId": "dol_1000", + "planName": "1000 Credits", + "orderType": "dol", + "vipDays": null, + "dolAmount": 1000, + "creditBalance": 1000, + "amountCents": 495, + "originalAmountCents": 990, + "dailyPriceCents": null, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + }, + { + "planId": "dol_2000", + "planName": "2000 Credits", + "orderType": "dol", + "vipDays": null, + "dolAmount": 2000, + "creditBalance": 2000, + "amountCents": 845, + "originalAmountCents": 1690, + "dailyPriceCents": null, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + }, + { + "planId": "dol_3000", + "planName": "3000 Credits", + "orderType": "dol", + "vipDays": null, + "dolAmount": 3000, + "creditBalance": 3000, + "amountCents": 1145, + "originalAmountCents": 2290, + "dailyPriceCents": null, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + }, + { + "planId": "dol_5000", + "planName": "5000 Credits", + "orderType": "dol", + "vipDays": null, + "dolAmount": 5000, + "creditBalance": 5000, + "amountCents": 1745, + "originalAmountCents": 3490, + "dailyPriceCents": null, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + }, + { + "planId": "dol_10000", + "planName": "10000 Credits", + "orderType": "dol", + "vipDays": null, + "dolAmount": 10000, + "creditBalance": 10000, + "amountCents": 3245, + "originalAmountCents": 6490, + "dailyPriceCents": null, + "currency": "USD", + "isFirstRechargeOffer": true, + "firstRechargeDiscountPercent": 50, + "promotionType": "first_recharge_half_price" + } + ] + } +} \ No newline at end of file diff --git a/src/data/schemas/payment/payment_plan.ts b/src/data/schemas/payment/payment_plan.ts index bfa4dcad..17ee8b68 100644 --- a/src/data/schemas/payment/payment_plan.ts +++ b/src/data/schemas/payment/payment_plan.ts @@ -14,6 +14,9 @@ export const PaymentPlanSchema = z.object({ originalAmountCents: z.number().nullable().default(null), dailyPriceCents: z.number().nullable().default(null), currency: z.string(), + isFirstRechargeOffer: z.boolean().default(false), + firstRechargeDiscountPercent: z.number().nullable().default(null), + promotionType: z.string().nullable().default(null), }); export type PaymentPlanInput = z.input; diff --git a/src/data/schemas/payment/payment_plans_response.ts b/src/data/schemas/payment/payment_plans_response.ts index b39c4248..d685abed 100644 --- a/src/data/schemas/payment/payment_plans_response.ts +++ b/src/data/schemas/payment/payment_plans_response.ts @@ -5,7 +5,15 @@ import { z } from "zod"; import { PaymentPlanSchema } from "./payment_plan"; +export const FirstRechargeOfferSchema = z.object({ + enabled: z.boolean(), + type: z.string(), + discountPercent: z.number(), +}); + export const PaymentPlansResponseSchema = z.object({ + isFirstRecharge: z.boolean().default(false), + firstRechargeOffer: FirstRechargeOfferSchema.nullable().default(null), plans: z.array(PaymentPlanSchema), }); @@ -15,3 +23,4 @@ export type PaymentPlansResponseInput = z.input< export type PaymentPlansResponseData = z.output< typeof PaymentPlansResponseSchema >; +export type FirstRechargeOfferData = z.output; diff --git a/src/stores/payment/__tests__/payment-machine.test.ts b/src/stores/payment/__tests__/payment-machine.test.ts index e8ccf56e..12580b27 100644 --- a/src/stores/payment/__tests__/payment-machine.test.ts +++ b/src/stores/payment/__tests__/payment-machine.test.ts @@ -145,6 +145,52 @@ describe("paymentMachine", () => { actor.stop(); }); + it("keeps first recharge offer metadata from the plans response", async () => { + const actor = createActor( + paymentMachine.provide({ + actors: { + loadCachedPlans: fromPromise( + async () => null, + ), + refreshPlans: fromPromise(async () => + PaymentPlansResponse.from({ + isFirstRecharge: true, + firstRechargeOffer: { + enabled: true, + type: "first_recharge_half_price", + discountPercent: 50, + }, + plans: [ + { + ...monthlyPlan, + amountCents: 995, + originalAmountCents: 1990, + isFirstRechargeOffer: true, + firstRechargeDiscountPercent: 50, + promotionType: "first_recharge_half_price", + }, + ], + }), + ), + }, + }), + ).start(); + + actor.send({ type: "PaymentInit" }); + await waitFor(actor, (snapshot) => snapshot.matches("ready")); + + const context = actor.getSnapshot().context; + expect(context.isFirstRecharge).toBe(true); + expect(context.firstRechargeOffer?.discountPercent).toBe(50); + expect(context.plans[0]).toMatchObject({ + amountCents: 995, + originalAmountCents: 1990, + isFirstRechargeOffer: true, + }); + + actor.stop(); + }); + it("hydrates cached plans before refreshing with network plans", async () => { let resolveRefresh!: (value: PaymentPlansResponse) => void; const refreshPlansPromise = new Promise((resolve) => { diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index be2d0d8c..49be932a 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -21,6 +21,8 @@ import type { export interface PaymentContextState { status: string; plans: MachineContext["plans"]; + isFirstRecharge: MachineContext["isFirstRecharge"]; + firstRechargeOffer: MachineContext["firstRechargeOffer"]; selectedPlanId: string; payChannel: MachineContext["payChannel"]; autoRenew: boolean; @@ -50,6 +52,8 @@ export function PaymentProvider({ children }: PaymentProviderProps) { () => ({ status: String(state.value), plans: state.context.plans, + isFirstRecharge: state.context.isFirstRecharge, + firstRechargeOffer: state.context.firstRechargeOffer, selectedPlanId: state.context.selectedPlanId, payChannel: state.context.payChannel, autoRenew: state.context.autoRenew, diff --git a/src/stores/payment/payment-machine.helpers.ts b/src/stores/payment/payment-machine.helpers.ts index e839574c..2102c4fe 100644 --- a/src/stores/payment/payment-machine.helpers.ts +++ b/src/stores/payment/payment-machine.helpers.ts @@ -1,4 +1,4 @@ -import type { PaymentPlan } from "@/data/dto/payment"; +import type { PaymentPlan, PaymentPlansResponse } from "@/data/dto/payment"; import type { PaymentState } from "./payment-state"; @@ -44,7 +44,10 @@ export function getDefaultPlanId(plans: readonly PaymentPlan[]): string { export function hydratePlansState( plans: PaymentPlan[], selectedPlanId: string, -): Pick { +): Pick< + PaymentState, + "plans" | "selectedPlanId" | "autoRenew" | "errorMessage" +> { const nextSelectedPlanId = selectedPlanId || getDefaultPlanId(plans); return { plans, @@ -54,10 +57,32 @@ export function hydratePlansState( }; } +export function hydratePlansResponseState( + response: PaymentPlansResponse, + selectedPlanId: string, +): Pick< + PaymentState, + | "plans" + | "isFirstRecharge" + | "firstRechargeOffer" + | "selectedPlanId" + | "autoRenew" + | "errorMessage" +> { + return { + ...hydratePlansState(response.plans, selectedPlanId), + isFirstRecharge: response.isFirstRecharge, + firstRechargeOffer: response.firstRechargeOffer, + }; +} + export function refreshPlansState( plans: PaymentPlan[], selectedPlanId: string, -): Pick { +): Pick< + PaymentState, + "plans" | "selectedPlanId" | "autoRenew" | "errorMessage" +> { const nextSelectedPlanId = plans.some((plan) => plan.planId === selectedPlanId) ? selectedPlanId : getDefaultPlanId(plans); @@ -69,6 +94,25 @@ export function refreshPlansState( }; } +export function refreshPlansResponseState( + response: PaymentPlansResponse, + selectedPlanId: string, +): Pick< + PaymentState, + | "plans" + | "isFirstRecharge" + | "firstRechargeOffer" + | "selectedPlanId" + | "autoRenew" + | "errorMessage" +> { + return { + ...refreshPlansState(response.plans, selectedPlanId), + isFirstRecharge: response.isFirstRecharge, + firstRechargeOffer: response.firstRechargeOffer, + }; +} + export function selectPlanState( planId: string, plans: readonly PaymentPlan[], diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index bdeaf037..493f1184 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -13,9 +13,9 @@ import { } from "./payment-machine.actors"; import { hasOrderPollingTimedOut, - hydratePlansState, + hydratePlansResponseState, PAYMENT_TIMEOUT_ERROR_MESSAGE, - refreshPlansState, + refreshPlansResponseState, resetOrderState, selectPlanState, toPaymentErrorMessage, @@ -63,8 +63,11 @@ export const paymentMachine = setup({ guard: ({ event }) => (event.output?.plans.length ?? 0) > 0, target: "refreshingPlans", actions: assign(({ context, event }) => { - const plans = event.output?.plans ?? []; - return hydratePlansState(plans, context.selectedPlanId); + if (!event.output) return {}; + return hydratePlansResponseState( + event.output, + context.selectedPlanId, + ); }), }, { @@ -83,8 +86,10 @@ export const paymentMachine = setup({ onDone: { target: "ready", actions: assign(({ context, event }) => { - const plans = event.output.plans; - return hydratePlansState(plans, context.selectedPlanId); + return hydratePlansResponseState( + event.output, + context.selectedPlanId, + ); }), }, onError: { @@ -102,8 +107,10 @@ export const paymentMachine = setup({ onDone: { target: "ready", actions: assign(({ context, event }) => { - const plans = event.output.plans; - return refreshPlansState(plans, context.selectedPlanId); + return refreshPlansResponseState( + event.output, + context.selectedPlanId, + ); }), }, onError: { diff --git a/src/stores/payment/payment-state.ts b/src/stores/payment/payment-state.ts index b83f5cf7..b73509ff 100644 --- a/src/stores/payment/payment-state.ts +++ b/src/stores/payment/payment-state.ts @@ -2,6 +2,7 @@ * Payment 状态机:State 形状 + 初始值 */ import type { + FirstRechargeOffer, PayChannel, PaymentOrderStatus, PaymentPlan, @@ -9,6 +10,8 @@ import type { export interface PaymentState { plans: PaymentPlan[]; + isFirstRecharge: boolean; + firstRechargeOffer: FirstRechargeOffer | null; selectedPlanId: string; payChannel: PayChannel; autoRenew: boolean; @@ -23,6 +26,8 @@ export interface PaymentState { export const initialState: PaymentState = { plans: [], + isFirstRecharge: false, + firstRechargeOffer: null, selectedPlanId: "", payChannel: "stripe", autoRenew: true,