feat(tip): support tiered coffee gifts

This commit is contained in:
2026-07-14 10:44:46 +08:00
parent 37ae152abb
commit 0fe74b5371
17 changed files with 307 additions and 41 deletions
+32 -2
View File
@@ -6,7 +6,7 @@
https://<APP_HOST>/external-entry
```
入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数
入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面。身份参数和入口控制参数会从地址栏中移除;目标页面自身需要的状态参数会保留,例如打赏页的 `coffee_type`
如果进入时已经是 Email、Google 或 Facebook 正式登录用户,并且携带 ASID 或 PSID,入口会额外调用一次 Facebook Identity 绑定接口。普通启动、刷新、Guest 和未登录状态不会调用绑定接口,绑定失败也不会阻止目标页面跳转。
@@ -19,6 +19,7 @@ https://<APP_HOST>/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://<APP_HOST>/external-entry
https://<APP_HOST>/external-entry?target=chat&asid=<ASID>&psid=<PSID>
```
进入打赏页面:
进入打赏页面(默认 Small Coffee
```text
https://<APP_HOST>/external-entry?target=tip
```
入口会跳转到规范地址:
```text
https://<APP_HOST>/tip?coffee_type=small
```
进入私密空间:
```text
https://<APP_HOST>/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://<APP_HOST>/external-entry?target=tip&coffee_type=small
https://<APP_HOST>/external-entry?target=tip&coffee_type=medium
https://<APP_HOST>/external-entry?target=tip&coffee_type=large
```
外部入口会将它们规范化为 `/tip?coffee_type=<type>``coffee_type` 会在登录、Stripe 回跳和 Ezpay 回跳期间保留,确保支付流程始终使用最初选择的咖啡档位。
实际创建订单时,前端只使用对应 `planId` 的后端套餐;Small 额外兼容旧套餐 `tip_coffee`。最终支付金额以后端返回的套餐数据为准,若找不到对应套餐,页面会禁止下单,避免使用错误档位。
## 促销入口
促销模式只在 `target=chat` 时生效,并且 `mode``promotion_type` 必须同时有效:
@@ -60,5 +89,6 @@ https://<APP_HOST>/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` 固定回退到聊天页。
@@ -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<PaymentEvent>;
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);
@@ -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(() => {
+2
View File
@@ -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)}
/>
@@ -29,34 +29,51 @@ function makePlan(input: Partial<PaymentPlanInput>): 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({
planId: "tip_coffee",
planName: "Coffee",
orderType: "custom_tip",
});
expect(findTipCoffeePlan([byOrderType, byPlanId])).toBe(byPlanId);
});
it("falls back to the tip coffee order type", () => {
it("uses the legacy tip coffee plan only for small", () => {
const plan = makePlan({
planId: "legacy_coffee",
orderType: "tip_coffee",
planId: "tip_coffee",
});
expect(findTipCoffeePlan([makePlan({}), plan])).toBe(plan);
expect(findTipCoffeePlan([makePlan({}), plan], "small")).toBe(plan);
expect(findTipCoffeePlan([plan], "medium")).toBeNull();
});
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([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", () => {
+7 -2
View File
@@ -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 ? (
<StripePaymentDialog
clientSecret={paymentLaunch.stripeClientSecret}
returnPath={ROUTES.tip}
returnPath={returnPath}
onClose={paymentLaunch.handleStripeClose}
onConfirmed={paymentLaunch.handleStripeConfirmed}
/>
+9
View File
@@ -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 (
<TipScreen
coffeeType={coffeeType}
shouldResumePendingOrder={searchParams.get("paymentReturn") === "1"}
initialPayChannel={toPayChannel(searchParams.get("payChannel"))}
/>
+19 -9
View File
@@ -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}`;
+17 -6
View File
@@ -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
</span>
<h2 className={styles.productName}>
{coffeePlan?.planName || "Latte"}
{coffeePlan?.planName || coffeeOption.fallbackName}
</h2>
<p className={styles.productPrice}>{priceLabel}</p>
</div>
@@ -231,12 +240,14 @@ export function TipScreen({
<div className={styles.checkoutSlot}>
<TipCheckoutButton
coffeeType={coffeeType}
disabled={
showMissingPlan ||
(!canCreateOrder && isRealLoginStatus(authState.loginStatus))
}
isAuthLoading={isAuthLoading}
onOrder={handleOrder}
returnPath={returnPath}
/>
</div>
</main>
@@ -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(),
});
@@ -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", () => {
+21
View File
@@ -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,
@@ -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");
});
});
+4
View File
@@ -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<void> {
@@ -93,6 +96,7 @@ export async function launchEzpayRedirect({
const saveResult = await savePendingEzpayOrder({
orderId,
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}),
});
if (Result.isErr(saveResult)) {
+12 -1
View File
@@ -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<Result<void>> {
@@ -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<Result<void>> {
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()}`;
}
+41
View File
@@ -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");
});
});
+57
View File
@@ -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<TipCoffeeType, TipCoffeeOption> = {
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()}`;
}