fix(private-room): preserve payment return route
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
import { act, type Dispatch } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { LoginStatus } from "@/data/dto/auth";
|
||||||
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import type { PrivateRoomEvent } from "@/stores/private-room";
|
||||||
|
import type { PrivateRoomUnlockPaywallRequest } from "@/stores/private-room/private-room-state";
|
||||||
|
|
||||||
|
import { usePrivateRoomUnlockPaywallNavigation } from "../use-private-room-flow";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
openAuth: vi.fn(),
|
||||||
|
openSubscription: vi.fn(),
|
||||||
|
paywallShown: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/router/use-app-navigator", () => ({
|
||||||
|
useAppNavigator: () => ({
|
||||||
|
openAuth: mocks.openAuth,
|
||||||
|
openSubscription: mocks.openSubscription,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/analytics", () => ({
|
||||||
|
behaviorAnalytics: {
|
||||||
|
paywallShown: mocks.paywallShown,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unlockPaywallRequest: PrivateRoomUnlockPaywallRequest = {
|
||||||
|
albumId: "album-1",
|
||||||
|
reason: "insufficient_credits",
|
||||||
|
requiredCredits: 10,
|
||||||
|
currentCredits: 3,
|
||||||
|
shortfallCredits: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Private Room paywall navigation", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
mocks.openAuth.mockReset();
|
||||||
|
mocks.openSubscription.mockReset();
|
||||||
|
mocks.paywallShown.mockReset();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens top up with a private room return target", () => {
|
||||||
|
const roomDispatch = vi.fn<Dispatch<PrivateRoomEvent>>();
|
||||||
|
|
||||||
|
renderHarness(root, "email", roomDispatch);
|
||||||
|
|
||||||
|
expect(mocks.openSubscription).toHaveBeenCalledWith({
|
||||||
|
type: "topup",
|
||||||
|
returnTo: "private-room",
|
||||||
|
analytics: {
|
||||||
|
entryPoint: "private_album_unlock",
|
||||||
|
triggerReason: "insufficient_credits",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(roomDispatch).toHaveBeenCalledWith({
|
||||||
|
type: "PrivateRoomUnlockPaywallConsumed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps guest users on the private room auth return path", () => {
|
||||||
|
const roomDispatch = vi.fn<Dispatch<PrivateRoomEvent>>();
|
||||||
|
|
||||||
|
renderHarness(root, "guest", roomDispatch);
|
||||||
|
|
||||||
|
expect(mocks.openAuth).toHaveBeenCalledWith(ROUTES.privateRoom);
|
||||||
|
expect(mocks.openSubscription).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderHarness(
|
||||||
|
root: Root,
|
||||||
|
loginStatus: LoginStatus,
|
||||||
|
roomDispatch: Dispatch<PrivateRoomEvent>,
|
||||||
|
): void {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<Harness loginStatus={loginStatus} roomDispatch={roomDispatch} />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness({
|
||||||
|
loginStatus,
|
||||||
|
roomDispatch,
|
||||||
|
}: {
|
||||||
|
loginStatus: LoginStatus;
|
||||||
|
roomDispatch: Dispatch<PrivateRoomEvent>;
|
||||||
|
}) {
|
||||||
|
usePrivateRoomUnlockPaywallNavigation({
|
||||||
|
loginStatus,
|
||||||
|
roomDispatch,
|
||||||
|
unlockPaywallRequest,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ export function PrivateRoomScreen() {
|
|||||||
}
|
}
|
||||||
navigator.openSubscription({
|
navigator.openSubscription({
|
||||||
type: "topup",
|
type: "topup",
|
||||||
|
returnTo: "private-room",
|
||||||
analytics: {
|
analytics: {
|
||||||
entryPoint: "private_room",
|
entryPoint: "private_room",
|
||||||
triggerReason: "vip_cta",
|
triggerReason: "vip_cta",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function usePrivateRoomUnlockPaywallNavigation({
|
|||||||
});
|
});
|
||||||
navigator.openSubscription({
|
navigator.openSubscription({
|
||||||
type: "topup",
|
type: "topup",
|
||||||
|
returnTo: "private-room",
|
||||||
analytics: {
|
analytics: {
|
||||||
entryPoint: "private_album_unlock",
|
entryPoint: "private_album_unlock",
|
||||||
triggerReason: "insufficient_credits",
|
triggerReason: "insufficient_credits",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||||
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
import {
|
import {
|
||||||
usePaymentDispatch,
|
usePaymentDispatch,
|
||||||
usePaymentState,
|
usePaymentState,
|
||||||
@@ -20,7 +21,7 @@ export interface SubscriptionCheckoutButtonProps {
|
|||||||
/** 是否可用(未选套餐 / 未勾选协议 → false) */
|
/** 是否可用(未选套餐 / 未勾选协议 → false) */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
subscriptionType: "vip" | "topup";
|
subscriptionType: "vip" | "topup";
|
||||||
returnTo?: "chat" | null;
|
returnTo?: SubscriptionReturnTo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubscriptionCheckoutButton({
|
export function SubscriptionCheckoutButton({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getFirstPaymentSearchParam,
|
getFirstPaymentSearchParam,
|
||||||
parsePaymentReturnSearchParams,
|
parsePaymentReturnSearchParams,
|
||||||
|
parseSubscriptionReturnTo,
|
||||||
type PaymentSearchParams,
|
type PaymentSearchParams,
|
||||||
} from "@/lib/payment/payment_search_params";
|
} from "@/lib/payment/payment_search_params";
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export default async function SubscriptionPage({
|
|||||||
<SubscriptionScreen
|
<SubscriptionScreen
|
||||||
subscriptionType={subscriptionType}
|
subscriptionType={subscriptionType}
|
||||||
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
shouldResumePendingOrder={paymentReturn.shouldResumePendingOrder}
|
||||||
returnTo={toReturnTo(getFirstPaymentSearchParam(query.returnTo))}
|
returnTo={parseSubscriptionReturnTo(query.returnTo)}
|
||||||
initialPayChannel={paymentReturn.initialPayChannel}
|
initialPayChannel={paymentReturn.initialPayChannel}
|
||||||
analyticsContext={analyticsContext}
|
analyticsContext={analyticsContext}
|
||||||
/>
|
/>
|
||||||
@@ -48,7 +49,3 @@ export default async function SubscriptionPage({
|
|||||||
function toSubscriptionType(value: string | null): SubscriptionType {
|
function toSubscriptionType(value: string | null): SubscriptionType {
|
||||||
return value === "topup" ? "topup" : "vip";
|
return value === "topup" ? "topup" : "vip";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toReturnTo(value: string | null): "chat" | null {
|
|
||||||
return value === "chat" ? "chat" : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Checkbox, MobileShell } from "@/app/_components/core";
|
|||||||
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
|
||||||
import { AppConstants } from "@/core/app_constants";
|
import { AppConstants } from "@/core/app_constants";
|
||||||
import type { PayChannel } from "@/data/dto/payment";
|
import type { PayChannel } from "@/data/dto/payment";
|
||||||
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
import {
|
import {
|
||||||
behaviorAnalytics,
|
behaviorAnalytics,
|
||||||
getDefaultPaymentAnalyticsContext,
|
getDefaultPaymentAnalyticsContext,
|
||||||
@@ -38,7 +39,7 @@ export type { SubscriptionType } from "./subscription-screen.helpers";
|
|||||||
export interface SubscriptionScreenProps {
|
export interface SubscriptionScreenProps {
|
||||||
subscriptionType?: SubscriptionType;
|
subscriptionType?: SubscriptionType;
|
||||||
shouldResumePendingOrder?: boolean;
|
shouldResumePendingOrder?: boolean;
|
||||||
returnTo?: "chat" | null;
|
returnTo?: SubscriptionReturnTo;
|
||||||
initialPayChannel?: PayChannel | null;
|
initialPayChannel?: PayChannel | null;
|
||||||
analyticsContext?: PaymentAnalyticsContext;
|
analyticsContext?: PaymentAnalyticsContext;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
import { usePaymentRouteFlow } from "@/app/_hooks/use-payment-route-flow";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import type { PayChannel } from "@/data/dto/payment";
|
import type { PayChannel } from "@/data/dto/payment";
|
||||||
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
|
|
||||||
import type { SubscriptionType } from "./subscription-screen.helpers";
|
import type { SubscriptionType } from "./subscription-screen.helpers";
|
||||||
|
|
||||||
export interface UseSubscriptionPaymentFlowInput {
|
export interface UseSubscriptionPaymentFlowInput {
|
||||||
subscriptionType: SubscriptionType;
|
subscriptionType: SubscriptionType;
|
||||||
shouldResumePendingOrder: boolean;
|
shouldResumePendingOrder: boolean;
|
||||||
returnTo: "chat" | null;
|
returnTo: SubscriptionReturnTo;
|
||||||
initialPayChannel: PayChannel;
|
initialPayChannel: PayChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const PendingPaymentOrderSchema = z.object({
|
|||||||
payChannel: z.literal("ezpay"),
|
payChannel: z.literal("ezpay"),
|
||||||
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
subscriptionType: z.enum(["vip", "topup", "tip"]),
|
||||||
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
|
tipCoffeeType: z.enum(["small", "medium", "large"]).optional(),
|
||||||
returnTo: z.enum(["chat"]).optional(),
|
returnTo: z.enum(["chat", "private-room"]).optional(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
consumeSubscriptionExplicitExitUrl,
|
consumeSubscriptionExplicitExitUrl,
|
||||||
getSubscriptionFallbackExitUrl,
|
getSubscriptionFallbackExitUrl,
|
||||||
peekSubscriptionExplicitExitUrl,
|
peekSubscriptionExplicitExitUrl,
|
||||||
|
resolveSubscriptionSuccessExitUrl,
|
||||||
} from "../subscription_exit";
|
} from "../subscription_exit";
|
||||||
|
|
||||||
vi.mock("../chat_image_return_session", () => ({
|
vi.mock("../chat_image_return_session", () => ({
|
||||||
@@ -65,6 +66,69 @@ describe("subscription exit helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns directly to private room without consuming chat state", async () => {
|
||||||
|
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||||
|
reason: "image_paywall",
|
||||||
|
messageId: "msg_1",
|
||||||
|
returnUrl: "/chat?image=msg_1",
|
||||||
|
createdAt: 1,
|
||||||
|
});
|
||||||
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
|
reason: "single_message_unlock",
|
||||||
|
displayMessageId: "msg_1",
|
||||||
|
messageId: "msg_1",
|
||||||
|
kind: "image",
|
||||||
|
returnUrl: "/chat?image=msg_1",
|
||||||
|
stage: "payment",
|
||||||
|
createdAt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getSubscriptionFallbackExitUrl("private-room")).toBe(
|
||||||
|
ROUTES.privateRoom,
|
||||||
|
);
|
||||||
|
await expect(consumeSubscriptionExitUrl("private-room")).resolves.toBe(
|
||||||
|
ROUTES.privateRoom,
|
||||||
|
);
|
||||||
|
expect(consumePendingChatImageReturnMock).not.toHaveBeenCalled();
|
||||||
|
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
|
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps private room ahead of stale chat state after payment", async () => {
|
||||||
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
|
reason: "single_message_unlock",
|
||||||
|
displayMessageId: "msg_1",
|
||||||
|
messageId: "msg_1",
|
||||||
|
kind: "image",
|
||||||
|
returnUrl: "/chat?image=msg_1",
|
||||||
|
stage: "payment",
|
||||||
|
createdAt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveSubscriptionSuccessExitUrl("private-room"),
|
||||||
|
).resolves.toBe(ROUTES.privateRoom);
|
||||||
|
expect(peekPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
|
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
||||||
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
|
reason: "single_message_unlock",
|
||||||
|
displayMessageId: "msg_1",
|
||||||
|
messageId: "msg_1",
|
||||||
|
kind: "image",
|
||||||
|
returnUrl: "/chat?image=msg_1",
|
||||||
|
stage: "payment",
|
||||||
|
createdAt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolveSubscriptionSuccessExitUrl("chat")).resolves.toBe(
|
||||||
|
"/chat?image=msg_1",
|
||||||
|
);
|
||||||
|
expect(clearPendingChatUnlockMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("peeks chat unlock return urls without clearing them", async () => {
|
it("peeks chat unlock return urls without clearing them", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||||
|
|
||||||
import { consumePendingChatImageReturn } from "./chat_image_return_session";
|
import { consumePendingChatImageReturn } from "./chat_image_return_session";
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
peekPendingChatUnlock,
|
peekPendingChatUnlock,
|
||||||
} from "./chat_unlock_session";
|
} from "./chat_unlock_session";
|
||||||
|
|
||||||
export type SubscriptionReturnTo = "chat" | null;
|
export type SubscriptionReturnTo = AppSubscriptionReturnTo;
|
||||||
|
|
||||||
export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> {
|
export async function consumeSubscriptionExplicitExitUrl(): Promise<string | null> {
|
||||||
const pendingImageReturn = await consumePendingChatImageReturn();
|
const pendingImageReturn = await consumePendingChatImageReturn();
|
||||||
@@ -30,14 +31,28 @@ export async function peekSubscriptionExplicitExitUrl(): Promise<string | null>
|
|||||||
export function getSubscriptionFallbackExitUrl(
|
export function getSubscriptionFallbackExitUrl(
|
||||||
returnTo: SubscriptionReturnTo,
|
returnTo: SubscriptionReturnTo,
|
||||||
): string {
|
): string {
|
||||||
return returnTo === "chat" ? ROUTES.chat : ROUTES.sidebar;
|
if (returnTo === "chat") return ROUTES.chat;
|
||||||
|
if (returnTo === "private-room") return ROUTES.privateRoom;
|
||||||
|
return ROUTES.sidebar;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumeSubscriptionExitUrl(
|
export async function consumeSubscriptionExitUrl(
|
||||||
returnTo: SubscriptionReturnTo,
|
returnTo: SubscriptionReturnTo,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
if (returnTo === "private-room") return ROUTES.privateRoom;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await consumeSubscriptionExplicitExitUrl()) ??
|
(await consumeSubscriptionExplicitExitUrl()) ??
|
||||||
getSubscriptionFallbackExitUrl(returnTo)
|
getSubscriptionFallbackExitUrl(returnTo)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveSubscriptionSuccessExitUrl(
|
||||||
|
returnTo: SubscriptionReturnTo,
|
||||||
|
): Promise<string> {
|
||||||
|
if (returnTo === "private-room") return ROUTES.privateRoom;
|
||||||
|
|
||||||
|
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
|
||||||
|
if (pendingExitUrl) return pendingExitUrl;
|
||||||
|
return consumeSubscriptionExitUrl(returnTo);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getFirstPaymentSearchParam,
|
getFirstPaymentSearchParam,
|
||||||
parsePaymentPayChannel,
|
parsePaymentPayChannel,
|
||||||
parsePaymentReturnSearchParams,
|
parsePaymentReturnSearchParams,
|
||||||
|
parseSubscriptionReturnTo,
|
||||||
} from "../payment_search_params";
|
} from "../payment_search_params";
|
||||||
|
|
||||||
describe("payment search params", () => {
|
describe("payment search params", () => {
|
||||||
@@ -35,4 +36,14 @@ describe("payment search params", () => {
|
|||||||
shouldResumePendingOrder: false,
|
shouldResumePendingOrder: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts only supported subscription return targets", () => {
|
||||||
|
expect(parseSubscriptionReturnTo("chat")).toBe("chat");
|
||||||
|
expect(parseSubscriptionReturnTo("private-room")).toBe("private-room");
|
||||||
|
expect(parseSubscriptionReturnTo(["private-room", "chat"])).toBe(
|
||||||
|
"private-room",
|
||||||
|
);
|
||||||
|
expect(parseSubscriptionReturnTo("tip")).toBeNull();
|
||||||
|
expect(parseSubscriptionReturnTo(undefined)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { buildPendingPaymentSubscriptionUrl } from "../pending_payment_order";
|
import {
|
||||||
|
buildPendingPaymentSubscriptionUrl,
|
||||||
|
clearPendingPaymentOrder,
|
||||||
|
getPendingPaymentOrder,
|
||||||
|
savePendingEzpayOrder,
|
||||||
|
} from "../pending_payment_order";
|
||||||
|
|
||||||
describe("pending payment order helpers", () => {
|
describe("pending payment order helpers", () => {
|
||||||
it("keeps the pay channel when rebuilding subscription return urls", () => {
|
it("keeps the pay channel when rebuilding subscription return urls", () => {
|
||||||
@@ -13,6 +18,40 @@ describe("pending payment order helpers", () => {
|
|||||||
).toBe("/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat");
|
).toBe("/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves private room returns when rebuilding Ezpay urls", () => {
|
||||||
|
expect(
|
||||||
|
buildPendingPaymentSubscriptionUrl({
|
||||||
|
payChannel: "ezpay",
|
||||||
|
returnTo: "private-room",
|
||||||
|
subscriptionType: "topup",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=private-room",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores private room as an Ezpay return target", async () => {
|
||||||
|
await clearPendingPaymentOrder();
|
||||||
|
|
||||||
|
const saveResult = await savePendingEzpayOrder({
|
||||||
|
orderId: "order-private-room",
|
||||||
|
returnTo: "private-room",
|
||||||
|
subscriptionType: "topup",
|
||||||
|
createdAt: 1,
|
||||||
|
});
|
||||||
|
const storedResult = await getPendingPaymentOrder();
|
||||||
|
|
||||||
|
expect(saveResult.success).toBe(true);
|
||||||
|
expect(storedResult).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orderId: "order-private-room",
|
||||||
|
returnTo: "private-room",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await clearPendingPaymentOrder();
|
||||||
|
});
|
||||||
|
|
||||||
it("routes tip payments back to the tip page", () => {
|
it("routes tip payments back to the tip page", () => {
|
||||||
expect(
|
expect(
|
||||||
buildPendingPaymentSubscriptionUrl({
|
buildPendingPaymentSubscriptionUrl({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PayChannel } from "@/data/dto/payment";
|
import type { PayChannel } from "@/data/dto/payment";
|
||||||
|
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||||
|
|
||||||
export type PaymentSearchParamValue = string | string[] | undefined;
|
export type PaymentSearchParamValue = string | string[] | undefined;
|
||||||
export type PaymentSearchParams = Record<string, PaymentSearchParamValue>;
|
export type PaymentSearchParams = Record<string, PaymentSearchParamValue>;
|
||||||
@@ -21,6 +22,14 @@ export function parsePaymentPayChannel(
|
|||||||
return channel === "ezpay" || channel === "stripe" ? channel : null;
|
return channel === "ezpay" || channel === "stripe" ? channel : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseSubscriptionReturnTo(
|
||||||
|
value: PaymentSearchParamValue,
|
||||||
|
): AppSubscriptionReturnTo {
|
||||||
|
const returnTo = getFirstPaymentSearchParam(value);
|
||||||
|
if (returnTo === "chat" || returnTo === "private-room") return returnTo;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePaymentReturnSearchParams(
|
export function parsePaymentReturnSearchParams(
|
||||||
searchParams: PaymentSearchParams,
|
searchParams: PaymentSearchParams,
|
||||||
): PaymentReturnSearchParams {
|
): PaymentReturnSearchParams {
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ describe("navigation resolver", () => {
|
|||||||
).toBe(ROUTE_BUILDERS.authWithRedirect(subscriptionUrl));
|
).toBe(ROUTE_BUILDERS.authWithRedirect(subscriptionUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds private room subscription return urls", () => {
|
||||||
|
expect(
|
||||||
|
ROUTE_BUILDERS.subscription("topup", {
|
||||||
|
payChannel: "stripe",
|
||||||
|
returnTo: "private-room",
|
||||||
|
}),
|
||||||
|
).toBe(
|
||||||
|
"/subscription?type=topup&payChannel=stripe&returnTo=private-room",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows not logged in users to enter chat for guest bootstrap", () => {
|
it("allows not logged in users to enter chat for guest bootstrap", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveRouteGuardRedirect({
|
resolveRouteGuardRedirect({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
} from "@/data/storage/navigation";
|
} from "@/data/storage/navigation";
|
||||||
|
|
||||||
export type AppSubscriptionType = "vip" | "topup";
|
export type AppSubscriptionType = "vip" | "topup";
|
||||||
export type AppSubscriptionReturnTo = "chat" | null;
|
export type AppSubscriptionReturnTo = "chat" | "private-room" | null;
|
||||||
|
|
||||||
export interface OpenSubscriptionInput {
|
export interface OpenSubscriptionInput {
|
||||||
type: AppSubscriptionType;
|
type: AppSubscriptionType;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PayChannel } from "@/data/dto/payment";
|
import type { PayChannel } from "@/data/dto/payment";
|
||||||
|
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
|
||||||
import {
|
import {
|
||||||
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
PAYMENT_ANALYTICS_ENTRY_PARAM,
|
||||||
PAYMENT_ANALYTICS_REASON_PARAM,
|
PAYMENT_ANALYTICS_REASON_PARAM,
|
||||||
@@ -36,7 +37,7 @@ export const ROUTE_BUILDERS = {
|
|||||||
type: "vip" | "topup",
|
type: "vip" | "topup",
|
||||||
options: {
|
options: {
|
||||||
payChannel?: PayChannel;
|
payChannel?: PayChannel;
|
||||||
returnTo?: "chat";
|
returnTo?: Exclude<AppSubscriptionReturnTo, null>;
|
||||||
analytics?: PaymentAnalyticsContext;
|
analytics?: PaymentAnalyticsContext;
|
||||||
} = {},
|
} = {},
|
||||||
): `/subscription?${string}` => {
|
): `/subscription?${string}` => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NavigationStorage } from "@/data/storage/navigation";
|
|||||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||||
import {
|
import {
|
||||||
consumeSubscriptionExitUrl,
|
consumeSubscriptionExitUrl,
|
||||||
peekSubscriptionExplicitExitUrl,
|
resolveSubscriptionSuccessExitUrl,
|
||||||
} from "@/lib/navigation/subscription_exit";
|
} from "@/lib/navigation/subscription_exit";
|
||||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||||
import {
|
import {
|
||||||
@@ -193,12 +193,7 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
|
|
||||||
const exitSubscriptionAfterSuccess = useCallback((returnTo: SubscriptionReturnTo): void => {
|
const exitSubscriptionAfterSuccess = useCallback((returnTo: SubscriptionReturnTo): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const pendingExitUrl = await peekSubscriptionExplicitExitUrl();
|
router.replace(await resolveSubscriptionSuccessExitUrl(returnTo));
|
||||||
if (pendingExitUrl) {
|
|
||||||
router.replace(pendingExitUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(await consumeSubscriptionExitUrl(returnTo));
|
|
||||||
})();
|
})();
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user