From 0fe74b5371c9f01978d400abdf1ff7eb8deb2ac0 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 14 Jul 2026 10:44:46 +0800 Subject: [PATCH] feat(tip): support tiered coffee gifts --- docs/external-entry.md | 34 ++++++++++- src/app/_hooks/use-payment-launch-flow.ts | 6 ++ .../external-entry/external-entry-persist.tsx | 10 +++- src/app/external-entry/page.tsx | 2 + .../tip/__tests__/tip-screen.helpers.test.ts | 53 +++++++++++------ src/app/tip/tip-checkout-button.tsx | 9 ++- src/app/tip/tip-page-client.tsx | 9 +++ src/app/tip/tip-screen.helpers.ts | 28 ++++++--- src/app/tip/tip-screen.tsx | 23 ++++++-- .../payment/pending_payment_order_storage.ts | 1 + .../__tests__/external_entry.test.ts | 25 ++++++++ src/lib/navigation/external_entry.ts | 21 +++++++ .../__tests__/pending_payment_order.test.ts | 12 +++- src/lib/payment/payment_launch.ts | 4 ++ src/lib/payment/pending_payment_order.ts | 13 ++++- src/lib/tip/__tests__/tip_coffee.test.ts | 41 +++++++++++++ src/lib/tip/tip_coffee.ts | 57 +++++++++++++++++++ 17 files changed, 307 insertions(+), 41 deletions(-) create mode 100644 src/lib/tip/__tests__/tip_coffee.test.ts create mode 100644 src/lib/tip/tip_coffee.ts diff --git a/docs/external-entry.md b/docs/external-entry.md index 08b90332..12294355 100644 --- a/docs/external-entry.md +++ b/docs/external-entry.md @@ -6,7 +6,7 @@ https:///external-entry ``` -入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数。 +入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面。身份参数和入口控制参数会从地址栏中移除;目标页面自身需要的状态参数会保留,例如打赏页的 `coffee_type`。 如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 ASID 或 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。 @@ -19,6 +19,7 @@ https:///external-entry | `asid` | 否 | string | Facebook App-scoped User ID。 | | `psid` | 否 | string | Facebook Page-scoped User ID。 | | `avatar_url` | 否 | URL string | 用户头像地址。 | +| `coffee_type` | 打赏入口可选 | `small`、`medium`、`large` | `target=tip` 时指定咖啡档位,缺失或非法时默认为 `small`。 | | `mode` | 否 | `promotion` | 设置为 `promotion` 时尝试开启聊天促销模式。 | | `promotion_type` | 促销模式必填 | `voice`、`image`、`private` | 指定促销锁消息类型。 | @@ -32,18 +33,46 @@ https:///external-entry https:///external-entry?target=chat&asid=&psid= ``` -进入打赏页面: +进入打赏页面(默认 Small Coffee): ```text https:///external-entry?target=tip ``` +入口会跳转到规范地址: + +```text +https:///tip?coffee_type=small +``` + 进入私密空间: ```text https:///external-entry?target=private-room ``` +## 咖啡打赏入口 + +打赏入口支持三种咖啡档位: + +| `coffee_type` | 展示名称 | 价格 | 后端套餐 `planId` | +| --- | --- | --- | --- | +| `small` | Small Coffee | US$4.99 | `tip_coffee_small` | +| `medium` | Medium Coffee | US$9.99 | `tip_coffee_medium` | +| `large` | Large Coffee | US$19.99 | `tip_coffee_large` | + +分别进入三种咖啡打赏页面: + +```text +https:///external-entry?target=tip&coffee_type=small +https:///external-entry?target=tip&coffee_type=medium +https:///external-entry?target=tip&coffee_type=large +``` + +外部入口会将它们规范化为 `/tip?coffee_type=`。`coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。 + +实际创建订单时,前端只使用对应 `planId` 的后端套餐;Small 额外兼容旧套餐 `tip_coffee`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。 + ## 促销入口 促销模式只在 `target=chat` 时生效,并且 `mode` 与 `promotion_type` 必须同时有效: @@ -60,5 +89,6 @@ https:///external-entry?target=chat&mode=promotion&promotion_type=voic - 所有参数必须使用 URL 编码,尤其是 `avatar_url`。 - 不要通过入口传递登录 token、Page Access Token 或 App Secret。 +- `coffee_type` 仅在 `target=tip` 时生效;缺失或非法时使用 `small`。 - `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。 - 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。 diff --git a/src/app/_hooks/use-payment-launch-flow.ts b/src/app/_hooks/use-payment-launch-flow.ts index 6ee28035..3e6702ec 100644 --- a/src/app/_hooks/use-payment-launch-flow.ts +++ b/src/app/_hooks/use-payment-launch-flow.ts @@ -11,6 +11,7 @@ import { import type { PendingPaymentReturnTo, PendingPaymentSubscriptionType, + PendingPaymentTipCoffeeType, } from "@/lib/payment/pending_payment_order"; import type { PaymentContextState, @@ -35,6 +36,7 @@ export interface UsePaymentLaunchFlowInput { paymentDispatch: Dispatch; returnTo?: PendingPaymentReturnTo; subscriptionType: PendingPaymentSubscriptionType; + tipCoffeeType?: PendingPaymentTipCoffeeType; } export interface PaymentLaunchFlow { @@ -72,6 +74,7 @@ export function usePaymentLaunchFlow({ paymentDispatch, returnTo, subscriptionType, + tipCoffeeType, }: UsePaymentLaunchFlowInput): PaymentLaunchFlow { const launchedNonceRef = useRef(0); const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState< @@ -112,6 +115,7 @@ export function usePaymentLaunchFlow({ orderId: payment.currentOrderId, paymentUrl, subscriptionType, + ...(tipCoffeeType ? { tipCoffeeType } : {}), ...(returnTo ? { returnTo } : {}), onFailed: (errorMessage) => paymentDispatch({ type: "PaymentLaunchFailed", errorMessage }), @@ -136,6 +140,7 @@ export function usePaymentLaunchFlow({ paymentDispatch, returnTo, subscriptionType, + tipCoffeeType, ]); const shouldShowStripeDialog = @@ -176,6 +181,7 @@ export function usePaymentLaunchFlow({ orderId: payment.currentOrderId, paymentUrl: ezpayPaymentUrl, subscriptionType, + ...(tipCoffeeType ? { tipCoffeeType } : {}), ...(returnTo ? { returnTo } : {}), onFailed: (errorMessage) => { setIsConfirmingEzpay(false); diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index 20c9c90e..fa158394 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { persistExternalEntryPayload, + resolveExternalEntryDestination, resolveExternalEntryPromotionType, resolveExternalEntryTarget, shouldBindExternalFacebookIdentity, @@ -25,6 +26,7 @@ interface ExternalEntryPersistProps { psid: string | null; avatarUrl: string | null; target: string | null; + coffeeType: string | null; mode: string | null; promotionType: string | null; } @@ -35,6 +37,7 @@ export default function ExternalEntryPersist({ psid, avatarUrl, target, + coffeeType, mode, promotionType, }: ExternalEntryPersistProps) { @@ -43,7 +46,8 @@ export default function ExternalEntryPersist({ const authDispatch = useAuthDispatch(); const [hasPersisted, setHasPersisted] = useState(false); const hasNavigatedRef = useRef(false); - const destination = resolveExternalEntryTarget({ target }); + const targetRoute = resolveExternalEntryTarget({ target }); + const destination = resolveExternalEntryDestination({ target, coffeeType }); const resolvedPromotionType = resolveExternalEntryPromotionType({ mode, promotionType, @@ -60,7 +64,7 @@ export default function ExternalEntryPersist({ psid, avatarUrl, }), - destination === ROUTES.chat && resolvedPromotionType + targetRoute === ROUTES.chat && resolvedPromotionType ? savePendingChatPromotion(resolvedPromotionType) : clearPendingChatPromotion(), ]); @@ -81,12 +85,14 @@ export default function ExternalEntryPersist({ }, [ avatarUrl, asid, + coffeeType, destination, deviceId, mode, promotionType, psid, resolvedPromotionType, + targetRoute, ]); useEffect(() => { diff --git a/src/app/external-entry/page.tsx b/src/app/external-entry/page.tsx index 0d90942d..78ad38c6 100644 --- a/src/app/external-entry/page.tsx +++ b/src/app/external-entry/page.tsx @@ -4,6 +4,7 @@ * 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如: * `/external-entry?target=chat&asid=xxx&psid=yyy` * `/external-entry?target=chat&mode=promotion&promotion_type=voice` + * `/external-entry?target=tip&coffee_type=medium` * * 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储, * 再通过 `router.replace()` 清理 URL 并跳转到最终页面。 @@ -29,6 +30,7 @@ export default async function ExternalEntryPage({ psid={pickParam(params.psid)} avatarUrl={pickParam(params.avatar_url)} target={pickParam(params.target)} + coffeeType={pickParam(params.coffee_type)} mode={pickParam(params.mode)} promotionType={pickParam(params.promotion_type)} /> diff --git a/src/app/tip/__tests__/tip-screen.helpers.test.ts b/src/app/tip/__tests__/tip-screen.helpers.test.ts index f903c489..14fbd2ad 100644 --- a/src/app/tip/__tests__/tip-screen.helpers.test.ts +++ b/src/app/tip/__tests__/tip-screen.helpers.test.ts @@ -29,34 +29,51 @@ function makePlan(input: Partial): PaymentPlan { } describe("tip screen helpers", () => { - it("prefers the exact tip coffee plan id", () => { - const byOrderType = makePlan({ - planId: "legacy_coffee", - planName: "Legacy coffee", - orderType: "tip_coffee", - }); - const byPlanId = makePlan({ + it("uses the legacy tip coffee plan only for small", () => { + const plan = makePlan({ planId: "tip_coffee", - planName: "Coffee", - orderType: "custom_tip", }); - expect(findTipCoffeePlan([byOrderType, byPlanId])).toBe(byPlanId); + expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan); + expect(findTipCoffeePlan([plan], "medium")).toBeNull(); }); - it("falls back to the tip coffee order type", () => { - const plan = makePlan({ - planId: "legacy_coffee", - orderType: "tip_coffee", + it("matches medium and large coffee plans independently", () => { + const medium = makePlan({ + planId: "tip_coffee_medium", + orderType: "tip_coffee_medium", + amountCents: 999, + }); + const large = makePlan({ + planId: "tip_coffee_large", + orderType: "tip_coffee_large", + amountCents: 1999, }); - expect(findTipCoffeePlan([makePlan({}), plan])).toBe(plan); + expect(findTipCoffeePlan([large, medium], "medium")).toBe(medium); + expect(findTipCoffeePlan([medium, large], "large")).toBe(large); + }); + + it("does not infer a coffee tier from order type or amount", () => { + const ambiguousPlan = makePlan({ + planId: "legacy_coffee", + orderType: "tip_coffee_medium", + amountCents: 999, + }); + + expect(findTipCoffeePlan([ambiguousPlan], "medium")).toBeNull(); }); it("formats USD prices with the product-style label", () => { - expect(formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }))).toBe( - "US$ 5", - ); + expect( + formatTipPrice(makePlan({ amountCents: 500, currency: "USD" }), "small"), + ).toBe("US$ 5"); + }); + + it("uses the selected coffee price when its plan is unavailable", () => { + expect(formatTipPrice(null, "small")).toBe("US$ 4.99"); + expect(formatTipPrice(null, "medium")).toBe("US$ 9.99"); + expect(formatTipPrice(null, "large")).toBe("US$ 19.99"); }); it("treats guest and notLoggedIn as non-real login states", () => { diff --git a/src/app/tip/tip-checkout-button.tsx b/src/app/tip/tip-checkout-button.tsx index 382fa508..c2c14b34 100644 --- a/src/app/tip/tip-checkout-button.tsx +++ b/src/app/tip/tip-checkout-button.tsx @@ -1,7 +1,7 @@ "use client"; import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow"; -import { ROUTES } from "@/router/routes"; +import type { TipCoffeeType } from "@/lib/tip/tip_coffee"; import { usePaymentDispatch, usePaymentState, @@ -15,15 +15,19 @@ import styles from "./tip-screen.module.css"; const log = new Logger("TipCheckoutButton"); export interface TipCheckoutButtonProps { + coffeeType: TipCoffeeType; disabled?: boolean; isAuthLoading?: boolean; onOrder: () => void; + returnPath: string; } export function TipCheckoutButton({ + coffeeType, disabled = false, isAuthLoading = false, onOrder, + returnPath, }: TipCheckoutButtonProps) { const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); @@ -33,6 +37,7 @@ export function TipCheckoutButton({ payment, paymentDispatch, subscriptionType: "tip", + tipCoffeeType: coffeeType, }); const isLoading = @@ -71,7 +76,7 @@ export function TipCheckoutButton({ {paymentLaunch.shouldShowStripeDialog && paymentLaunch.stripeClientSecret ? ( diff --git a/src/app/tip/tip-page-client.tsx b/src/app/tip/tip-page-client.tsx index 8c10f383..4922f659 100644 --- a/src/app/tip/tip-page-client.tsx +++ b/src/app/tip/tip-page-client.tsx @@ -3,6 +3,11 @@ import { useSearchParams } from "next/navigation"; import type { PayChannel } from "@/data/dto/payment"; +import { + DEFAULT_TIP_COFFEE_TYPE, + resolveTipCoffeeType, + TIP_COFFEE_TYPE_PARAM, +} from "@/lib/tip/tip_coffee"; import { TipScreen } from "./tip-screen"; @@ -13,9 +18,13 @@ function toPayChannel(value: string | null): PayChannel | null { export function TipPageClient() { const searchParams = useSearchParams(); + const coffeeType = + resolveTipCoffeeType(searchParams.get(TIP_COFFEE_TYPE_PARAM)) ?? + DEFAULT_TIP_COFFEE_TYPE; return ( diff --git a/src/app/tip/tip-screen.helpers.ts b/src/app/tip/tip-screen.helpers.ts index 4a0dd70e..b070bf75 100644 --- a/src/app/tip/tip-screen.helpers.ts +++ b/src/app/tip/tip-screen.helpers.ts @@ -1,27 +1,37 @@ import type { LoginStatus } from "@/data/dto/auth"; import type { PaymentPlan } from "@/data/dto/payment"; - -const TIP_COFFEE_PLAN_ID = "tip_coffee"; -const TIP_COFFEE_ORDER_TYPE = "tip_coffee"; +import { + getTipCoffeeOption, + type TipCoffeeType, +} from "@/lib/tip/tip_coffee"; export function findTipCoffeePlan( plans: readonly PaymentPlan[], + coffeeType: TipCoffeeType, ): PaymentPlan | null { + const option = getTipCoffeeOption(coffeeType); + return ( - plans.find((plan) => plan.planId === TIP_COFFEE_PLAN_ID) ?? - plans.find((plan) => plan.orderType === TIP_COFFEE_ORDER_TYPE) ?? + plans.find((plan) => plan.planId === option.planId) ?? + (coffeeType === "small" + ? (plans.find((plan) => plan.planId === "tip_coffee") ?? null) + : null) ?? null ); } -export function formatTipPrice(plan: PaymentPlan | null): string { - if (!plan) return "US$ 5"; +export function formatTipPrice( + plan: PaymentPlan | null, + coffeeType: TipCoffeeType, +): string { + const option = getTipCoffeeOption(coffeeType); + const amountCents = plan?.amountCents ?? option.amountCents; + const currency = plan?.currency.trim().toUpperCase() || "USD"; - const amount = plan.amountCents / 100; + const amount = amountCents / 100; const formattedAmount = Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, ""); - const currency = plan.currency.trim().toUpperCase(); if (currency === "USD") return `US$ ${formattedAmount}`; if (currency.length > 0) return `${currency} ${formattedAmount}`; diff --git a/src/app/tip/tip-screen.tsx b/src/app/tip/tip-screen.tsx index c0deae3f..50a07ce1 100644 --- a/src/app/tip/tip-screen.tsx +++ b/src/app/tip/tip-screen.tsx @@ -8,7 +8,12 @@ import { CharacterAvatar } from "@/app/_components"; import { MobileShell } from "@/app/_components/core"; import { usePaymentOrderLifecycle } from "@/app/_hooks/use-payment-order-lifecycle"; import type { PayChannel } from "@/data/dto/payment"; -import { ROUTES } from "@/router/routes"; +import { + buildTipCoffeePath, + DEFAULT_TIP_COFFEE_TYPE, + getTipCoffeeOption, + type TipCoffeeType, +} from "@/lib/tip/tip_coffee"; import { useAppNavigator } from "@/router/use-app-navigator"; import { useAuthState } from "@/stores/auth/auth-context"; import { @@ -25,11 +30,13 @@ import { import styles from "./tip-screen.module.css"; export interface TipScreenProps { + coffeeType?: TipCoffeeType; shouldResumePendingOrder?: boolean; initialPayChannel?: PayChannel | null; } export function TipScreen({ + coffeeType = DEFAULT_TIP_COFFEE_TYPE, shouldResumePendingOrder = false, initialPayChannel = null, }: TipScreenProps) { @@ -38,14 +45,16 @@ export function TipScreen({ const payment = usePaymentState(); const paymentDispatch = usePaymentDispatch(); const initialPayChannelAppliedRef = useRef(false); + const coffeeOption = getTipCoffeeOption(coffeeType); + const returnPath = buildTipCoffeePath(coffeeType); const resolvedInitialPayChannel = initialPayChannel ?? navigator.getDefaultPayChannel(); const coffeePlan = useMemo( - () => findTipCoffeePlan(payment.plans), - [payment.plans], + () => findTipCoffeePlan(payment.plans, coffeeType), + [coffeeType, payment.plans], ); - const priceLabel = formatTipPrice(coffeePlan); + const priceLabel = formatTipPrice(coffeePlan, coffeeType); const isPaymentBusy = payment.isCreatingOrder || payment.isPollingOrder || payment.isPaid; const canCreateOrder = @@ -127,7 +136,7 @@ export function TipScreen({ const handleOrder = () => { if (isAuthLoading) return; if (!isRealLoginStatus(authState.loginStatus)) { - navigator.openAuth(ROUTES.tip); + navigator.openAuth(returnPath); return; } if (!canCreateOrder) return; @@ -198,7 +207,7 @@ export function TipScreen({ Coffee Gift

- {coffeePlan?.planName || "Latte"} + {coffeePlan?.planName || coffeeOption.fallbackName}

{priceLabel}

@@ -231,12 +240,14 @@ export function TipScreen({
diff --git a/src/data/storage/payment/pending_payment_order_storage.ts b/src/data/storage/payment/pending_payment_order_storage.ts index 259dbcde..7740bbc9 100644 --- a/src/data/storage/payment/pending_payment_order_storage.ts +++ b/src/data/storage/payment/pending_payment_order_storage.ts @@ -10,6 +10,7 @@ const PendingPaymentOrderSchema = z.object({ orderId: z.string().min(1), payChannel: z.literal("ezpay"), subscriptionType: z.enum(["vip", "topup", "tip"]), + tipCoffeeType: z.enum(["small", "medium", "large"]).optional(), returnTo: z.enum(["chat"]).optional(), createdAt: z.number(), }); diff --git a/src/lib/navigation/__tests__/external_entry.test.ts b/src/lib/navigation/__tests__/external_entry.test.ts index f1609f63..31e25ee0 100644 --- a/src/lib/navigation/__tests__/external_entry.test.ts +++ b/src/lib/navigation/__tests__/external_entry.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { ROUTES } from "@/router/routes"; import { + resolveExternalEntryDestination, resolveExternalEntryPromotionType, resolveExternalEntryTarget, shouldBindExternalFacebookIdentity, @@ -30,6 +31,30 @@ describe("external entry navigation", () => { expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat); expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat); }); + + it("routes tip entries to the requested coffee type", () => { + expect( + resolveExternalEntryDestination({ + target: "tip", + coffeeType: "medium", + }), + ).toBe("/tip?coffee_type=medium"); + expect( + resolveExternalEntryDestination({ + target: "tip", + coffeeType: "large", + }), + ).toBe("/tip?coffee_type=large"); + }); + + it("defaults invalid tip coffee types to small", () => { + expect( + resolveExternalEntryDestination({ + target: "tip", + coffeeType: "unknown", + }), + ).toBe("/tip?coffee_type=small"); + }); }); describe("external entry facebook identity binding", () => { diff --git a/src/lib/navigation/external_entry.ts b/src/lib/navigation/external_entry.ts index be45a7f9..6243bb1d 100644 --- a/src/lib/navigation/external_entry.ts +++ b/src/lib/navigation/external_entry.ts @@ -5,6 +5,11 @@ import { UserStorage } from "@/data/storage/user/user_storage"; import type { PendingChatPromotionType } from "@/data/storage/navigation"; import type { LoginStatus } from "@/data/dto/auth"; import { ROUTES } from "@/router/routes"; +import { + buildTipCoffeePath, + DEFAULT_TIP_COFFEE_TYPE, + resolveTipCoffeeType, +} from "@/lib/tip/tip_coffee"; export type ExternalEntryTarget = | typeof ROUTES.chat @@ -22,6 +27,10 @@ export interface ExternalEntryTargetInput { target?: string | null; } +export interface ExternalEntryDestinationInput extends ExternalEntryTargetInput { + coffeeType?: string | null; +} + export interface ExternalEntryPromotionInput { mode?: string | null; promotionType?: string | null; @@ -77,6 +86,18 @@ export function resolveExternalEntryTarget({ return resolveTarget(target) ?? ROUTES.chat; } +export function resolveExternalEntryDestination({ + target, + coffeeType, +}: ExternalEntryDestinationInput): string { + const resolvedTarget = resolveExternalEntryTarget({ target }); + if (resolvedTarget !== ROUTES.tip) return resolvedTarget; + + return buildTipCoffeePath( + resolveTipCoffeeType(coffeeType) ?? DEFAULT_TIP_COFFEE_TYPE, + ); +} + export async function persistExternalEntryPayload({ deviceId, asid, diff --git a/src/lib/payment/__tests__/pending_payment_order.test.ts b/src/lib/payment/__tests__/pending_payment_order.test.ts index 029b9037..477d7399 100644 --- a/src/lib/payment/__tests__/pending_payment_order.test.ts +++ b/src/lib/payment/__tests__/pending_payment_order.test.ts @@ -18,7 +18,17 @@ describe("pending payment order helpers", () => { buildPendingPaymentSubscriptionUrl({ payChannel: "ezpay", subscriptionType: "tip", + tipCoffeeType: "large", }), - ).toBe("/tip?payChannel=ezpay&paymentReturn=1"); + ).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large"); + }); + + it("defaults legacy tip payment returns to small coffee", () => { + expect( + buildPendingPaymentSubscriptionUrl({ + payChannel: "ezpay", + subscriptionType: "tip", + }), + ).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small"); }); }); diff --git a/src/lib/payment/payment_launch.ts b/src/lib/payment/payment_launch.ts index d533b76e..61d3ca96 100644 --- a/src/lib/payment/payment_launch.ts +++ b/src/lib/payment/payment_launch.ts @@ -6,6 +6,7 @@ import { savePendingEzpayOrder, type PendingPaymentReturnTo, type PendingPaymentSubscriptionType, + type PendingPaymentTipCoffeeType, } from "./pending_payment_order"; const log = new Logger("LibPaymentPaymentLaunch"); @@ -61,6 +62,7 @@ export interface LaunchEzpayRedirectInput { orderId: string | null; paymentUrl: string; subscriptionType: PendingPaymentSubscriptionType; + tipCoffeeType?: PendingPaymentTipCoffeeType; returnTo?: PendingPaymentReturnTo; onFailed: (errorMessage: string) => void; } @@ -69,6 +71,7 @@ export async function launchEzpayRedirect({ orderId, paymentUrl, subscriptionType, + tipCoffeeType, returnTo, onFailed, }: LaunchEzpayRedirectInput): Promise { @@ -93,6 +96,7 @@ export async function launchEzpayRedirect({ const saveResult = await savePendingEzpayOrder({ orderId, subscriptionType, + ...(tipCoffeeType ? { tipCoffeeType } : {}), ...(returnTo ? { returnTo } : {}), }); if (Result.isErr(saveResult)) { diff --git a/src/lib/payment/pending_payment_order.ts b/src/lib/payment/pending_payment_order.ts index b35d3606..aa4a609f 100644 --- a/src/lib/payment/pending_payment_order.ts +++ b/src/lib/payment/pending_payment_order.ts @@ -5,15 +5,23 @@ import { type PendingPaymentOrder, } from "@/data/storage"; import { ROUTES } from "@/router/routes"; +import { + DEFAULT_TIP_COFFEE_TYPE, + TIP_COFFEE_TYPE_PARAM, +} from "@/lib/tip/tip_coffee"; import type { Result } from "@/utils"; export type PendingPaymentSubscriptionType = PendingPaymentOrder["subscriptionType"]; export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"]; +export type PendingPaymentTipCoffeeType = NonNullable< + PendingPaymentOrder["tipCoffeeType"] +>; export function savePendingEzpayOrder(input: { orderId: string; subscriptionType: PendingPaymentSubscriptionType; + tipCoffeeType?: PendingPaymentTipCoffeeType; returnTo?: PendingPaymentReturnTo; createdAt?: number; }): Promise> { @@ -21,6 +29,7 @@ export function savePendingEzpayOrder(input: { orderId: input.orderId, payChannel: "ezpay", subscriptionType: input.subscriptionType, + ...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}), ...(input.returnTo ? { returnTo: input.returnTo } : {}), createdAt: input.createdAt ?? Date.now(), }); @@ -45,13 +54,15 @@ export function clearPendingPaymentOrder(): Promise> { export function buildPendingPaymentSubscriptionUrl( order: Pick< PendingPaymentOrder, - "payChannel" | "returnTo" | "subscriptionType" + "payChannel" | "returnTo" | "subscriptionType" | "tipCoffeeType" >, ): string { if (order.subscriptionType === "tip") { const params = new URLSearchParams({ payChannel: order.payChannel, paymentReturn: "1", + [TIP_COFFEE_TYPE_PARAM]: + order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE, }); return `${ROUTES.tip}?${params.toString()}`; } diff --git a/src/lib/tip/__tests__/tip_coffee.test.ts b/src/lib/tip/__tests__/tip_coffee.test.ts new file mode 100644 index 00000000..c72b621f --- /dev/null +++ b/src/lib/tip/__tests__/tip_coffee.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { + buildTipCoffeePath, + getTipCoffeeOption, + resolveTipCoffeeType, +} from "../tip_coffee"; + +describe("tip coffee configuration", () => { + it("resolves supported types case-insensitively", () => { + expect(resolveTipCoffeeType(" Small ")).toBe("small"); + expect(resolveTipCoffeeType("MEDIUM")).toBe("medium"); + expect(resolveTipCoffeeType("large")).toBe("large"); + }); + + it("rejects missing and unsupported types", () => { + expect(resolveTipCoffeeType(null)).toBeNull(); + expect(resolveTipCoffeeType("espresso")).toBeNull(); + }); + + it("provides the configured plan and fallback price for each type", () => { + expect(getTipCoffeeOption("small")).toMatchObject({ + amountCents: 499, + planId: "tip_coffee_small", + }); + expect(getTipCoffeeOption("medium")).toMatchObject({ + amountCents: 999, + planId: "tip_coffee_medium", + }); + expect(getTipCoffeeOption("large")).toMatchObject({ + amountCents: 1999, + planId: "tip_coffee_large", + }); + }); + + it("builds canonical tip paths", () => { + expect(buildTipCoffeePath("small")).toBe("/tip?coffee_type=small"); + expect(buildTipCoffeePath("medium")).toBe("/tip?coffee_type=medium"); + expect(buildTipCoffeePath("large")).toBe("/tip?coffee_type=large"); + }); +}); diff --git a/src/lib/tip/tip_coffee.ts b/src/lib/tip/tip_coffee.ts new file mode 100644 index 00000000..b13d0c8d --- /dev/null +++ b/src/lib/tip/tip_coffee.ts @@ -0,0 +1,57 @@ +import { ROUTES } from "@/router/routes"; + +export const TIP_COFFEE_TYPE_PARAM = "coffee_type"; +export const DEFAULT_TIP_COFFEE_TYPE = "small"; + +export type TipCoffeeType = "small" | "medium" | "large"; + +export interface TipCoffeeOption { + type: TipCoffeeType; + amountCents: number; + fallbackName: string; + planId: string; +} + +const TIP_COFFEE_OPTIONS: Record = { + small: { + type: "small", + amountCents: 499, + fallbackName: "Small Coffee", + planId: "tip_coffee_small", + }, + medium: { + type: "medium", + amountCents: 999, + fallbackName: "Medium Coffee", + planId: "tip_coffee_medium", + }, + large: { + type: "large", + amountCents: 1999, + fallbackName: "Large Coffee", + planId: "tip_coffee_large", + }, +}; + +export function resolveTipCoffeeType( + value: string | null | undefined, +): TipCoffeeType | null { + const normalized = value?.trim().toLowerCase(); + if ( + normalized === "small" || + normalized === "medium" || + normalized === "large" + ) { + return normalized; + } + return null; +} + +export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption { + return TIP_COFFEE_OPTIONS[type]; +} + +export function buildTipCoffeePath(type: TipCoffeeType): string { + const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type }); + return `${ROUTES.tip}?${params.toString()}`; +}