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
@@ -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()}`;
}