feat(tip): support dynamic gift products

This commit is contained in:
2026-07-21 13:19:45 +08:00
parent 55cb98ed14
commit 37ff69020b
62 changed files with 2325 additions and 1085 deletions
@@ -88,26 +88,27 @@ describe("pending payment order helpers", () => {
await clearPendingPaymentOrder();
});
it("routes tip payments back to the tip page", () => {
it("routes tip payments back with dynamic category and plan id", () => {
expect(
buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay",
subscriptionType: "tip",
tipCoffeeType: "large",
giftCategory: "coffee",
giftPlanId: "tip_coffee_usd_19_99",
}),
).toBe(
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large",
"/characters/elio/tip?category=coffee&planId=tip_coffee_usd_19_99&payChannel=ezpay&paymentReturn=1",
);
});
it("defaults legacy tip payment returns to medium coffee", () => {
it("lets legacy tip records fall back to the first backend product", () => {
expect(
buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay",
subscriptionType: "tip",
}),
).toBe(
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium",
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1",
);
});
@@ -116,11 +117,12 @@ describe("pending payment order helpers", () => {
buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay",
subscriptionType: "tip",
tipCoffeeType: "small",
giftCategory: "coffee",
giftPlanId: "tip_coffee_usd_4_99",
characterSlug: "nayeli",
}),
).toBe(
"/characters/nayeli/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small",
"/characters/nayeli/tip?category=coffee&planId=tip_coffee_usd_4_99&payChannel=ezpay&paymentReturn=1",
);
});
});
+6 -4
View File
@@ -7,7 +7,6 @@ import {
savePendingEzpayOrder,
type PendingPaymentReturnTo,
type PendingPaymentSubscriptionType,
type PendingPaymentTipCoffeeType,
} from "./pending_payment_order";
const log = new Logger("LibPaymentPaymentLaunch");
@@ -63,7 +62,8 @@ export interface LaunchEzpayRedirectInput {
orderId: string | null;
paymentUrl: string;
subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
giftCategory?: string | null;
giftPlanId?: string | null;
returnTo?: PendingPaymentReturnTo;
characterSlug?: string;
onOpened?: () => void;
@@ -74,7 +74,8 @@ export async function launchEzpayRedirect({
orderId,
paymentUrl,
subscriptionType,
tipCoffeeType,
giftCategory,
giftPlanId,
returnTo,
characterSlug,
onOpened,
@@ -101,7 +102,8 @@ export async function launchEzpayRedirect({
const saveResult = await savePendingEzpayOrder({
orderId,
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
giftCategory,
giftPlanId,
...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}),
});
+16 -18
View File
@@ -9,23 +9,18 @@ import {
getCharacterBySlug,
} from "@/data/constants/character";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import {
DEFAULT_TIP_COFFEE_TYPE,
TIP_COFFEE_TYPE_PARAM,
} from "@/lib/tip/tip_coffee";
import { buildTipGiftPath } from "@/lib/tip/tip_gift";
import type { Result } from "@/utils/result";
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;
giftCategory?: string | null;
giftPlanId?: string | null;
returnTo?: PendingPaymentReturnTo;
characterSlug?: string;
createdAt?: number;
@@ -34,7 +29,8 @@ export function savePendingEzpayOrder(input: {
orderId: input.orderId,
payChannel: "ezpay",
subscriptionType: input.subscriptionType,
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
giftCategory: input.giftCategory ?? null,
giftPlanId: input.giftPlanId ?? null,
...(input.returnTo ? { returnTo: input.returnTo } : {}),
...(input.characterSlug ? { characterSlug: input.characterSlug } : {}),
createdAt: input.createdAt ?? Date.now(),
@@ -63,22 +59,24 @@ export function buildPendingPaymentSubscriptionUrl(
| "payChannel"
| "returnTo"
| "subscriptionType"
| "tipCoffeeType"
| "characterSlug"
>,
> &
Partial<Pick<PendingPaymentOrder, "giftCategory" | "giftPlanId">>,
): string {
const characterSlug =
getCharacterBySlug(order.characterSlug)?.slug ?? DEFAULT_CHARACTER_SLUG;
const characterRoutes = getCharacterRoutes(characterSlug);
if (order.subscriptionType === "tip") {
const params = new URLSearchParams({
payChannel: order.payChannel,
paymentReturn: "1",
[TIP_COFFEE_TYPE_PARAM]:
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
});
return `${characterRoutes.tip}?${params.toString()}`;
const path = buildTipGiftPath(
{
category: order.giftCategory ?? null,
planId: order.giftPlanId ?? null,
},
characterRoutes.tip,
);
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}payChannel=${order.payChannel}&paymentReturn=1`;
}
const params = new URLSearchParams({
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildTipCoffeePath,
DEFAULT_TIP_COFFEE_TYPE,
getTipCoffeeOption,
resolveTipCoffeeType,
TIP_COFFEE_OPTIONS,
} 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,
displayName: "Velvet Espresso",
image: { src: "/images/tip/small.jpg", width: 736, height: 736 },
planId: "tip_coffee_usd_4_99",
});
expect(getTipCoffeeOption("medium")).toMatchObject({
amountCents: 999,
displayName: "Gilded Heart",
image: { src: "/images/tip/medium.png", width: 1024, height: 1024 },
planId: "tip_coffee_usd_9_99",
});
expect(getTipCoffeeOption("large")).toMatchObject({
amountCents: 1999,
displayName: "Crown Blossom",
image: { src: "/images/tip/large.png", width: 1024, height: 1024 },
planId: "tip_coffee_usd_19_99",
});
});
it("lists all tiers in display order and defaults to medium", () => {
expect(TIP_COFFEE_OPTIONS.map(({ type }) => type)).toEqual([
"small",
"medium",
"large",
]);
expect(DEFAULT_TIP_COFFEE_TYPE).toBe("medium");
});
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");
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
buildTipGiftPath,
normalizeTipGiftParam,
} from "../tip_gift";
describe("tip gift navigation", () => {
it("normalizes optional gift query values", () => {
expect(normalizeTipGiftParam(" coffee ")).toBe("coffee");
expect(normalizeTipGiftParam(" ")).toBeNull();
expect(normalizeTipGiftParam(null)).toBeNull();
});
it("builds canonical category and plan return paths", () => {
expect(
buildTipGiftPath({ category: "coffee", planId: "gift_1" }),
).toBe("/tip?category=coffee&planId=gift_1");
});
it("omits missing selection values without restoring coffee_type", () => {
expect(buildTipGiftPath({ category: null, planId: null })).toBe("/tip");
});
});
-86
View File
@@ -1,86 +0,0 @@
import { ROUTES } from "@/router/routes";
export const TIP_COFFEE_TYPE_PARAM = "coffee_type";
export const DEFAULT_TIP_COFFEE_TYPE = "medium";
export type TipCoffeeType = "small" | "medium" | "large";
export interface TipCoffeeOption {
readonly type: TipCoffeeType;
readonly amountCents: number;
readonly displayName: string;
readonly image: {
readonly src: string;
readonly width: number;
readonly height: number;
};
readonly planId: string;
}
const TIP_COFFEE_OPTION_BY_TYPE: Record<TipCoffeeType, TipCoffeeOption> = {
small: {
type: "small",
amountCents: 499,
displayName: "Velvet Espresso",
image: {
src: "/images/tip/small.jpg",
width: 736,
height: 736,
},
planId: "tip_coffee_usd_4_99",
},
medium: {
type: "medium",
amountCents: 999,
displayName: "Gilded Heart",
image: {
src: "/images/tip/medium.png",
width: 1024,
height: 1024,
},
planId: "tip_coffee_usd_9_99",
},
large: {
type: "large",
amountCents: 1999,
displayName: "Crown Blossom",
image: {
src: "/images/tip/large.png",
width: 1024,
height: 1024,
},
planId: "tip_coffee_usd_19_99",
},
};
export const TIP_COFFEE_OPTIONS: readonly TipCoffeeOption[] = [
TIP_COFFEE_OPTION_BY_TYPE.small,
TIP_COFFEE_OPTION_BY_TYPE.medium,
TIP_COFFEE_OPTION_BY_TYPE.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_OPTION_BY_TYPE[type];
}
export function buildTipCoffeePath(
type: TipCoffeeType,
basePath: string = ROUTES.tip,
): string {
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
return `${basePath}?${params.toString()}`;
}
+31
View File
@@ -0,0 +1,31 @@
import { ROUTES } from "@/router/routes";
export const TIP_GIFT_CATEGORY_PARAM = "category";
export const TIP_GIFT_PLAN_ID_PARAM = "planId";
export interface TipGiftSelection {
category: string | null;
planId: string | null;
}
export function normalizeTipGiftParam(
value: string | null | undefined,
): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
}
export function buildTipGiftPath(
selection: TipGiftSelection,
basePath: string = ROUTES.tip,
): string {
const params = new URLSearchParams();
if (selection.category) {
params.set(TIP_GIFT_CATEGORY_PARAM, selection.category);
}
if (selection.planId) {
params.set(TIP_GIFT_PLAN_ID_PARAM, selection.planId);
}
const query = params.toString();
return query ? `${basePath}?${query}` : basePath;
}