feat(tip): support dynamic gift products
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user