feat(characters): use local character catalog

This commit is contained in:
2026-07-17 16:03:18 +08:00
parent a210a98d98
commit b3ebd5cf3b
96 changed files with 1023 additions and 522 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { ROUTES } from "@/router/routes";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import {
resolveExternalEntryDestination,
@@ -33,7 +33,21 @@ describe("external entry navigation", () => {
});
it("routes every tip entry to the tier selection page", () => {
expect(resolveExternalEntryDestination({ target: "tip" })).toBe("/tip");
expect(resolveExternalEntryDestination({ target: "tip" })).toBe(
getCharacterRoutes("elio").tip,
);
});
it("uses a known character slug and falls back to Elio", () => {
expect(
resolveExternalEntryDestination({ target: "chat", character: "maya" }),
).toBe(getCharacterRoutes("maya").chat);
expect(
resolveExternalEntryDestination({
target: "private-room",
character: "unknown",
}),
).toBe(getCharacterRoutes("elio").privateRoom);
});
});
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ROUTES } from "@/router/routes";
import { getCharacterRoutes } from "@/router/routes";
import { consumePendingChatImageReturn } from "../chat_image_return_session";
import {
@@ -53,16 +53,20 @@ describe("subscription exit helpers", () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null);
await expect(consumeSubscriptionExitUrl("chat")).resolves.toBe(ROUTES.chat);
await expect(consumeSubscriptionExitUrl("chat")).resolves.toBe(
getCharacterRoutes("elio").chat,
);
});
it("falls back to sidebar by default", async () => {
consumePendingChatImageReturnMock.mockResolvedValue(null);
peekPendingChatUnlockMock.mockResolvedValue(null);
expect(getSubscriptionFallbackExitUrl(null)).toBe(ROUTES.sidebar);
expect(getSubscriptionFallbackExitUrl(null)).toBe(
getCharacterRoutes("elio").sidebar,
);
await expect(consumeSubscriptionExitUrl(null)).resolves.toBe(
ROUTES.sidebar,
getCharacterRoutes("elio").sidebar,
);
});
@@ -84,10 +88,10 @@ describe("subscription exit helpers", () => {
});
expect(getSubscriptionFallbackExitUrl("private-room")).toBe(
ROUTES.privateRoom,
getCharacterRoutes("elio").privateRoom,
);
await expect(consumeSubscriptionExitUrl("private-room")).resolves.toBe(
ROUTES.privateRoom,
getCharacterRoutes("elio").privateRoom,
);
expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled();
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
@@ -106,8 +110,8 @@ describe("subscription exit helpers", () => {
});
await expect(
resolveSubscriptionSuccessExitUrl("private-room"),
).resolves.toBe(ROUTES.privateRoom);
resolveSubscriptionSuccessExitUrl("private-room", "maya"),
).resolves.toBe(getCharacterRoutes("maya").privateRoom);
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
});
+14 -2
View File
@@ -4,7 +4,11 @@ import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage";
import type { PendingChatPromotionType } from "@/data/storage/navigation";
import type { LoginStatus } from "@/data/schemas/auth";
import { ROUTES } from "@/router/routes";
import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
export type ExternalEntryTarget =
| typeof ROUTES.chat
@@ -20,6 +24,7 @@ export interface ExternalEntryPayload {
export interface ExternalEntryTargetInput {
target?: string | null;
character?: string | null;
}
export interface ExternalEntryPromotionInput {
@@ -79,8 +84,15 @@ export function resolveExternalEntryTarget({
export function resolveExternalEntryDestination({
target,
character,
}: ExternalEntryTargetInput): string {
return resolveExternalEntryTarget({ target });
const characterSlug =
getCharacterBySlug(character)?.slug ?? DEFAULT_CHARACTER_SLUG;
const routes = getCharacterRoutes(characterSlug);
const resolvedTarget = resolveExternalEntryTarget({ target });
if (resolvedTarget === ROUTES.tip) return routes.tip;
if (resolvedTarget === ROUTES.privateRoom) return routes.privateRoom;
return routes.chat;
}
export async function persistExternalEntryPayload({
+17 -8
View File
@@ -1,6 +1,7 @@
"use client";
import { ROUTES } from "@/router/routes";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import { getCharacterRoutes } from "@/router/routes";
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
import { consumePendingChatImageReturn } from "./chat_image_return_session";
@@ -30,29 +31,37 @@ export async function peekSubscriptionExplicitExitUrl(): Promise<string | null>
export function getSubscriptionFallbackExitUrl(
returnTo: SubscriptionReturnTo,
characterSlug: string = DEFAULT_CHARACTER_SLUG,
): string {
if (returnTo === "chat") return ROUTES.chat;
if (returnTo === "private-room") return ROUTES.privateRoom;
return ROUTES.sidebar;
const routes = getCharacterRoutes(characterSlug);
if (returnTo === "chat") return routes.chat;
if (returnTo === "private-room") return routes.privateRoom;
return routes.sidebar;
}
export async function consumeSubscriptionExitUrl(
returnTo: SubscriptionReturnTo,
characterSlug: string = DEFAULT_CHARACTER_SLUG,
): Promise<string> {
if (returnTo === "private-room") return ROUTES.privateRoom;
if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom;
}
return (
(await consumeSubscriptionExplicitExitUrl()) ??
getSubscriptionFallbackExitUrl(returnTo)
getSubscriptionFallbackExitUrl(returnTo, characterSlug)
);
}
export async function resolveSubscriptionSuccessExitUrl(
returnTo: SubscriptionReturnTo,
characterSlug: string = DEFAULT_CHARACTER_SLUG,
): Promise<string> {
if (returnTo === "private-room") return ROUTES.privateRoom;
if (returnTo === "private-room") {
return getCharacterRoutes(characterSlug).privateRoom;
}
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
if (pendingExitUrl) return pendingExitUrl;
return consumeSubscriptionExitUrl(returnTo);
return consumeSubscriptionExitUrl(returnTo, characterSlug);
}
@@ -15,7 +15,9 @@ describe("pending payment order helpers", () => {
returnTo: "chat",
subscriptionType: "topup",
}),
).toBe("/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat");
).toBe(
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat&character=elio",
);
});
it("preserves private room returns when rebuilding Ezpay urls", () => {
@@ -26,7 +28,7 @@ describe("pending payment order helpers", () => {
subscriptionType: "topup",
}),
).toBe(
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=private-room",
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=private-room&character=elio",
);
});
@@ -59,7 +61,9 @@ describe("pending payment order helpers", () => {
subscriptionType: "tip",
tipCoffeeType: "large",
}),
).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large");
).toBe(
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large",
);
});
it("defaults legacy tip payment returns to medium coffee", () => {
@@ -68,6 +72,21 @@ describe("pending payment order helpers", () => {
payChannel: "ezpay",
subscriptionType: "tip",
}),
).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium");
).toBe(
"/characters/elio/tip?payChannel=ezpay&paymentReturn=1&coffee_type=medium",
);
});
it("restores the character that started an Ezpay tip", () => {
expect(
buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay",
subscriptionType: "tip",
tipCoffeeType: "small",
characterSlug: "nayeli",
}),
).toBe(
"/characters/nayeli/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small",
);
});
});
+3
View File
@@ -65,6 +65,7 @@ export interface LaunchEzpayRedirectInput {
subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
returnTo?: PendingPaymentReturnTo;
characterSlug?: string;
onOpened?: () => void;
onFailed: (errorMessage: string) => void;
}
@@ -75,6 +76,7 @@ export async function launchEzpayRedirect({
subscriptionType,
tipCoffeeType,
returnTo,
characterSlug,
onOpened,
onFailed,
}: LaunchEzpayRedirectInput): Promise<void> {
@@ -101,6 +103,7 @@ export async function launchEzpayRedirect({
subscriptionType,
...(tipCoffeeType ? { tipCoffeeType } : {}),
...(returnTo ? { returnTo } : {}),
...(characterSlug ? { characterSlug } : {}),
});
if (Result.isErr(saveResult)) {
const errorMessage =
+18 -3
View File
@@ -4,7 +4,11 @@ import {
PendingPaymentOrderStorage,
type PendingPaymentOrder,
} from "@/data/storage/payment/pending_payment_order_storage";
import { ROUTES } from "@/router/routes";
import {
DEFAULT_CHARACTER_SLUG,
getCharacterBySlug,
} from "@/data/constants/character";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import {
DEFAULT_TIP_COFFEE_TYPE,
TIP_COFFEE_TYPE_PARAM,
@@ -23,6 +27,7 @@ export function savePendingEzpayOrder(input: {
subscriptionType: PendingPaymentSubscriptionType;
tipCoffeeType?: PendingPaymentTipCoffeeType;
returnTo?: PendingPaymentReturnTo;
characterSlug?: string;
createdAt?: number;
}): Promise<Result<void>> {
return PendingPaymentOrderStorage.setPendingOrder({
@@ -31,6 +36,7 @@ export function savePendingEzpayOrder(input: {
subscriptionType: input.subscriptionType,
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
...(input.returnTo ? { returnTo: input.returnTo } : {}),
...(input.characterSlug ? { characterSlug: input.characterSlug } : {}),
createdAt: input.createdAt ?? Date.now(),
});
}
@@ -54,9 +60,17 @@ export function clearPendingPaymentOrder(): Promise<Result<void>> {
export function buildPendingPaymentSubscriptionUrl(
order: Pick<
PendingPaymentOrder,
"payChannel" | "returnTo" | "subscriptionType" | "tipCoffeeType"
| "payChannel"
| "returnTo"
| "subscriptionType"
| "tipCoffeeType"
| "characterSlug"
>,
): 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,
@@ -64,7 +78,7 @@ export function buildPendingPaymentSubscriptionUrl(
[TIP_COFFEE_TYPE_PARAM]:
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
});
return `${ROUTES.tip}?${params.toString()}`;
return `${characterRoutes.tip}?${params.toString()}`;
}
const params = new URLSearchParams({
@@ -73,5 +87,6 @@ export function buildPendingPaymentSubscriptionUrl(
paymentReturn: "1",
});
if (order.returnTo) params.set("returnTo", order.returnTo);
params.set("character", characterSlug);
return `${ROUTES.subscription}?${params.toString()}`;
}
+5 -2
View File
@@ -77,7 +77,10 @@ export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption {
return TIP_COFFEE_OPTION_BY_TYPE[type];
}
export function buildTipCoffeePath(type: TipCoffeeType): string {
export function buildTipCoffeePath(
type: TipCoffeeType,
basePath: string = ROUTES.tip,
): string {
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
return `${ROUTES.tip}?${params.toString()}`;
return `${basePath}?${params.toString()}`;
}