fix(subscription): preselect pay channel before navigation

This commit is contained in:
2026-07-02 11:38:47 +08:00
parent 35c8de917d
commit cd25fa35f2
15 changed files with 121 additions and 56 deletions
+12 -4
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import type { PayChannel } from "@/data/dto/payment";
import { ROUTE_BUILDERS } from "@/router/routes"; import { ROUTE_BUILDERS } from "@/router/routes";
import { AppEnvUtil, BrowserDetector } from "@/utils"; import { AppEnvUtil, BrowserDetector } from "@/utils";
@@ -30,14 +31,20 @@ export function shouldStartExternalBrowserPrompt({
); );
} }
export function getChatPaywallSubscriptionUrl(): string { export function getChatPaywallSubscriptionUrl(
payChannel: PayChannel = "stripe",
): string {
return ROUTE_BUILDERS.subscription("vip", { return ROUTE_BUILDERS.subscription("vip", {
payChannel,
returnTo: "chat", returnTo: "chat",
}); });
} }
export function getChatCreditsTopUpSubscriptionUrl(): string { export function getChatCreditsTopUpSubscriptionUrl(
payChannel: PayChannel = "stripe",
): string {
return ROUTE_BUILDERS.subscription("topup", { return ROUTE_BUILDERS.subscription("topup", {
payChannel,
returnTo: "chat", returnTo: "chat",
}); });
} }
@@ -51,11 +58,12 @@ export function getInsufficientCreditsSubscriptionType(
export function getChatPaywallNavigationUrl( export function getChatPaywallNavigationUrl(
loginStatus: LoginStatus, loginStatus: LoginStatus,
type: "vip" | "topup" = "vip", type: "vip" | "topup" = "vip",
payChannel: PayChannel = "stripe",
): string { ): string {
const subscriptionUrl = const subscriptionUrl =
type === "topup" type === "topup"
? getChatCreditsTopUpSubscriptionUrl() ? getChatCreditsTopUpSubscriptionUrl(payChannel)
: getChatPaywallSubscriptionUrl(); : getChatPaywallSubscriptionUrl(payChannel);
if (deriveIsGuest(loginStatus)) { if (deriveIsGuest(loginStatus)) {
return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl); return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl);
} }
+5
View File
@@ -13,6 +13,7 @@ import {
recordExternalBrowserPromptShown, recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility, resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser"; } from "@/lib/chat/chat_external_browser";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
@@ -115,10 +116,14 @@ export function ChatScreen() {
} }
function handleMessageLimitUnlock(): void { function handleMessageLimitUnlock(): void {
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push( router.push(
getChatPaywallNavigationUrl( getChatPaywallNavigationUrl(
authState.loginStatus, authState.loginStatus,
getInsufficientCreditsSubscriptionType(userState.isVip), getInsufficientCreditsSubscriptionType(userState.isVip),
defaultPayChannel,
), ),
); );
} }
@@ -15,6 +15,7 @@ import {
type PendingChatUnlock, type PendingChatUnlock,
type PendingChatUnlockKind, type PendingChatUnlockKind,
} from "@/lib/navigation/chat_unlock_session"; } from "@/lib/navigation/chat_unlock_session";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
@@ -135,10 +136,13 @@ export function useChatUnlockNavigationFlow({
stage: "payment", stage: "payment",
}); });
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push( router.push(
ROUTE_BUILDERS.subscription( ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip), getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" }, { payChannel: defaultPayChannel, returnTo: "chat" },
), ),
); );
})(); })();
+13 -5
View File
@@ -7,7 +7,8 @@ import { useRouter } from "next/navigation";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { ROUTES } from "@/router/routes"; import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context";
@@ -20,8 +21,6 @@ import {
import styles from "./components/sidebar-screen.module.css"; import styles from "./components/sidebar-screen.module.css";
const FALLBACK_USERNAME = "User name"; const FALLBACK_USERNAME = "User name";
const VIP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=vip`;
const TOP_UP_SUBSCRIPTION_URL = `${ROUTES.subscription}?type=topup`;
export function SidebarScreen() { export function SidebarScreen() {
const router = useRouter(); const router = useRouter();
@@ -61,6 +60,15 @@ export function SidebarScreen() {
const name = user.currentUser?.username ?? FALLBACK_USERNAME; const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null; const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
const defaultPayChannel = getDefaultPayChannelForCountryCode(
user.currentUser?.countryCode,
);
const vipSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", {
payChannel: defaultPayChannel,
});
const topUpSubscriptionUrl = ROUTE_BUILDERS.subscription("topup", {
payChannel: defaultPayChannel,
});
return ( return (
<MobileShell background="#fcf3f4"> <MobileShell background="#fcf3f4">
@@ -88,9 +96,9 @@ export function SidebarScreen() {
onActivateVip={ onActivateVip={
sidebarState === "vip" sidebarState === "vip"
? undefined ? undefined
: () => router.push(VIP_SUBSCRIPTION_URL) : () => router.push(vipSubscriptionUrl)
} }
onTopUp={() => router.push(TOP_UP_SUBSCRIPTION_URL)} onTopUp={() => router.push(topUpSubscriptionUrl)}
onRulesClick={() => router.push(ROUTES.coinsRules)} onRulesClick={() => router.push(ROUTES.coinsRules)}
/> />
</section> </section>
@@ -62,7 +62,7 @@ describe("subscription screen helpers", () => {
{ {
id: "coin_1000", id: "coin_1000",
coins: 1000, coins: 1000,
price: "9.90", price: "9.9",
currency: "US$", currency: "US$",
}, },
]); ]);
@@ -98,7 +98,7 @@ describe("subscription screen helpers", () => {
).toMatchObject([ ).toMatchObject([
{ {
id: "vip_test", id: "vip_test",
price: "77.90", price: "77.9",
}, },
]); ]);
}); });
@@ -138,7 +138,7 @@ describe("subscription screen helpers", () => {
{ {
id: "coin_1000", id: "coin_1000",
coins: 1000, coins: 1000,
price: "9.90", price: "9.9",
currency: "US$", currency: "US$",
}, },
], ],
@@ -156,7 +156,7 @@ describe("subscription screen helpers", () => {
selectedPlan: { selectedPlan: {
id: "coin_1000", id: "coin_1000",
coins: 1000, coins: 1000,
price: "9.90", price: "9.9",
currency: "US$", currency: "US$",
}, },
vipPlans: [], vipPlans: [],
@@ -164,7 +164,7 @@ describe("subscription screen helpers", () => {
{ {
id: "coin_1000", id: "coin_1000",
coins: 1000, coins: 1000,
price: "9.90", price: "9.9",
currency: "US$", currency: "US$",
}, },
], ],
@@ -2,6 +2,8 @@
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { PayChannel } from "@/data/dto/payment";
import { import {
SubscriptionScreen, SubscriptionScreen,
type SubscriptionType, type SubscriptionType,
@@ -15,17 +17,23 @@ function toReturnTo(value: string | null): "chat" | null {
return value === "chat" ? "chat" : null; return value === "chat" ? "chat" : null;
} }
function toPayChannel(value: string | null): PayChannel {
return value === "ezpay" ? "ezpay" : "stripe";
}
export function SubscriptionPageClient() { export function SubscriptionPageClient() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const subscriptionType = toSubscriptionType(searchParams.get("type")); const subscriptionType = toSubscriptionType(searchParams.get("type"));
const shouldResumePendingOrder = searchParams.get("paymentReturn") === "1"; const shouldResumePendingOrder = searchParams.get("paymentReturn") === "1";
const returnTo = toReturnTo(searchParams.get("returnTo")); const returnTo = toReturnTo(searchParams.get("returnTo"));
const initialPayChannel = toPayChannel(searchParams.get("payChannel"));
return ( return (
<SubscriptionScreen <SubscriptionScreen
subscriptionType={subscriptionType} subscriptionType={subscriptionType}
shouldResumePendingOrder={shouldResumePendingOrder} shouldResumePendingOrder={shouldResumePendingOrder}
returnTo={returnTo} returnTo={returnTo}
initialPayChannel={initialPayChannel}
/> />
); );
} }
@@ -1,16 +1,11 @@
import type { PayChannel, PaymentPlan } from "@/data/dto/payment"; import type { PaymentPlan } from "@/data/dto/payment";
export { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section"; import type { CoinsOfferPlanView } from "./components/subscription-coins-offer-section";
import type { VipOfferPlanView } from "./components/subscription-vip-offer-section"; import type { VipOfferPlanView } from "./components/subscription-vip-offer-section";
export type SubscriptionType = "vip" | "topup"; export type SubscriptionType = "vip" | "topup";
export function getDefaultPayChannelForCountryCode(
countryCode: string | null | undefined,
): PayChannel {
return countryCode?.trim().toUpperCase() === "PH" ? "ezpay" : "stripe";
}
export function isVipPlan(plan: PaymentPlan): boolean { export function isVipPlan(plan: PaymentPlan): boolean {
return plan.vipDays !== null; return plan.vipDays !== null;
} }
+5 -28
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo } from "react";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { Checkbox, MobileShell } from "@/app/_components/core"; import { Checkbox, MobileShell } from "@/app/_components/core";
import { AppConstants } from "@/core/app_constants"; import { AppConstants } from "@/core/app_constants";
import { useUserState } from "@/stores/user/user-context"; import type { PayChannel } from "@/data/dto/payment";
import { import {
SubscriptionCheckoutButton, SubscriptionCheckoutButton,
@@ -16,7 +16,6 @@ import {
import styles from "./components/subscription-screen.module.css"; import styles from "./components/subscription-screen.module.css";
import { import {
findSelectedSubscriptionPlan, findSelectedSubscriptionPlan,
getDefaultPayChannelForCountryCode,
getDefaultSubscriptionPlanId, getDefaultSubscriptionPlanId,
toCoinsOfferPlanViews, toCoinsOfferPlanViews,
toVipOfferPlanViews, toVipOfferPlanViews,
@@ -30,12 +29,14 @@ export interface SubscriptionScreenProps {
subscriptionType?: SubscriptionType; subscriptionType?: SubscriptionType;
shouldResumePendingOrder?: boolean; shouldResumePendingOrder?: boolean;
returnTo?: "chat" | null; returnTo?: "chat" | null;
initialPayChannel?: PayChannel;
} }
export function SubscriptionScreen({ export function SubscriptionScreen({
subscriptionType = "vip", subscriptionType = "vip",
shouldResumePendingOrder = false, shouldResumePendingOrder = false,
returnTo = null, returnTo = null,
initialPayChannel = "stripe",
}: SubscriptionScreenProps) { }: SubscriptionScreenProps) {
const { const {
payment, payment,
@@ -47,9 +48,8 @@ export function SubscriptionScreen({
subscriptionType, subscriptionType,
shouldResumePendingOrder, shouldResumePendingOrder,
returnTo, returnTo,
initialPayChannel,
}); });
const user = useUserState();
const paymentMethodManuallySelectedRef = useRef(false);
const canSubscribeVip = subscriptionType === "vip"; const canSubscribeVip = subscriptionType === "vip";
const vipOfferPlans = useMemo( const vipOfferPlans = useMemo(
@@ -100,28 +100,6 @@ export function SubscriptionScreen({
vipOfferPlans, vipOfferPlans,
]); ]);
useEffect(() => {
if (payment.status !== "ready") return;
if (paymentMethodManuallySelectedRef.current) return;
const countryCode = user.currentUser?.countryCode ?? "";
if (countryCode.trim().length === 0) return;
const defaultPayChannel =
getDefaultPayChannelForCountryCode(countryCode);
if (payment.payChannel === defaultPayChannel) return;
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel: defaultPayChannel,
});
}, [
payment.payChannel,
payment.status,
paymentDispatch,
user.currentUser?.countryCode,
]);
return ( return (
<MobileShell> <MobileShell>
<div className={styles.shell}> <div className={styles.shell}>
@@ -163,7 +141,6 @@ export function SubscriptionScreen({
value={payment.payChannel} value={payment.payChannel}
disabled={isPaymentBusy} disabled={isPaymentBusy}
onChange={(payChannel) => { onChange={(payChannel) => {
paymentMethodManuallySelectedRef.current = true;
paymentDispatch({ paymentDispatch({
type: "PaymentPayChannelChanged", type: "PaymentPayChannelChanged",
payChannel, payChannel,
@@ -16,6 +16,7 @@ import {
usePaymentState, usePaymentState,
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { useUserDispatch } from "@/stores/user/user-context"; import { useUserDispatch } from "@/stores/user/user-context";
import type { PayChannel } from "@/data/dto/payment";
import type { SubscriptionType } from "./subscription-screen.helpers"; import type { SubscriptionType } from "./subscription-screen.helpers";
@@ -23,12 +24,14 @@ export interface UseSubscriptionPaymentFlowInput {
subscriptionType: SubscriptionType; subscriptionType: SubscriptionType;
shouldResumePendingOrder: boolean; shouldResumePendingOrder: boolean;
returnTo: "chat" | null; returnTo: "chat" | null;
initialPayChannel: PayChannel;
} }
export function useSubscriptionPaymentFlow({ export function useSubscriptionPaymentFlow({
subscriptionType, subscriptionType,
shouldResumePendingOrder, shouldResumePendingOrder,
returnTo, returnTo,
initialPayChannel,
}: UseSubscriptionPaymentFlowInput) { }: UseSubscriptionPaymentFlowInput) {
const router = useRouter(); const router = useRouter();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
@@ -37,14 +40,37 @@ export function useSubscriptionPaymentFlow({
const refreshedPaidOrderRef = useRef<string | null>(null); const refreshedPaidOrderRef = useRef<string | null>(null);
const resumedPendingOrderRef = useRef<string | null>(null); const resumedPendingOrderRef = useRef<string | null>(null);
const successDialogShownOrderRef = useRef<string | null>(null); const successDialogShownOrderRef = useRef<string | null>(null);
const initialPayChannelAppliedRef = useRef(false);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
useState(false); useState(false);
useEffect(() => { useEffect(() => {
if (payment.status === "idle") { if (payment.status === "idle") {
paymentDispatch({ type: "PaymentInit" }); initialPayChannelAppliedRef.current = true;
paymentDispatch({
type: "PaymentInit",
payChannel: initialPayChannel,
});
return;
} }
}, [payment.status, paymentDispatch]);
if (
!initialPayChannelAppliedRef.current &&
payment.status === "ready"
) {
initialPayChannelAppliedRef.current = true;
if (payment.payChannel === initialPayChannel) return;
paymentDispatch({
type: "PaymentPayChannelChanged",
payChannel: initialPayChannel,
});
}
}, [
initialPayChannel,
payment.payChannel,
payment.status,
paymentDispatch,
]);
useEffect(() => { useEffect(() => {
if (!payment.isPaid || !payment.currentOrderId) return; if (!payment.isPaid || !payment.currentOrderId) return;
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { buildPendingPaymentSubscriptionUrl } from "../pending_payment_order";
describe("pending payment order helpers", () => {
it("keeps the pay channel when rebuilding subscription return urls", () => {
expect(
buildPendingPaymentSubscriptionUrl({
payChannel: "ezpay",
returnTo: "chat",
subscriptionType: "topup",
}),
).toBe("/subscription?type=topup&payChannel=ezpay&paymentReturn=1&returnTo=chat");
});
});
+7
View File
@@ -0,0 +1,7 @@
import type { PayChannel } from "@/data/dto/payment";
export function getDefaultPayChannelForCountryCode(
countryCode: string | null | undefined,
): PayChannel {
return countryCode?.trim().toUpperCase() === "PH" ? "ezpay" : "stripe";
}
+5 -1
View File
@@ -43,10 +43,14 @@ export function clearPendingPaymentOrder(): Promise<Result<void>> {
} }
export function buildPendingPaymentSubscriptionUrl( export function buildPendingPaymentSubscriptionUrl(
order: Pick<PendingPaymentOrder, "subscriptionType" | "returnTo">, order: Pick<
PendingPaymentOrder,
"payChannel" | "returnTo" | "subscriptionType"
>,
): string { ): string {
const params = new URLSearchParams({ const params = new URLSearchParams({
type: order.subscriptionType, type: order.subscriptionType,
payChannel: order.payChannel,
paymentReturn: "1", paymentReturn: "1",
}); });
if (order.returnTo) params.set("returnTo", order.returnTo); if (order.returnTo) params.set("returnTo", order.returnTo);
+4 -1
View File
@@ -14,6 +14,8 @@
* error / not-found / page / sidebar-screen / splash-screen)使用 —— 不能整体注释。 * error / not-found / page / sidebar-screen / splash-screen)使用 —— 不能整体注释。
*/ */
import type { PayChannel } from "@/data/dto/payment";
/** 静态路由字面量 */ /** 静态路由字面量 */
export const ROUTES = { export const ROUTES = {
root: "/", root: "/",
@@ -35,9 +37,10 @@ export const ROUTE_BUILDERS = {
`/chat/image/${encodeURIComponent(messageId)}` as const, `/chat/image/${encodeURIComponent(messageId)}` as const,
subscription: ( subscription: (
type: "vip" | "topup", type: "vip" | "topup",
options: { returnTo?: "chat" } = {}, options: { payChannel?: PayChannel; returnTo?: "chat" } = {},
): `/subscription?${string}` => { ): `/subscription?${string}` => {
const params = new URLSearchParams({ type }); const params = new URLSearchParams({ type });
if (options.payChannel) params.set("payChannel", options.payChannel);
if (options.returnTo) params.set("returnTo", options.returnTo); if (options.returnTo) params.set("returnTo", options.returnTo);
return `${ROUTES.subscription}?${params.toString()}` as const; return `${ROUTES.subscription}?${params.toString()}` as const;
}, },
+1 -1
View File
@@ -4,7 +4,7 @@
import type { PayChannel } from "@/data/dto/payment"; import type { PayChannel } from "@/data/dto/payment";
export type PaymentEvent = export type PaymentEvent =
| { type: "PaymentInit" } | { type: "PaymentInit"; payChannel?: PayChannel }
| { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPlanSelected"; planId: string }
| { type: "PaymentPayChannelChanged"; payChannel: PayChannel } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel }
| { type: "PaymentAutoRenewChanged"; autoRenew: boolean } | { type: "PaymentAutoRenewChanged"; autoRenew: boolean }
+6 -1
View File
@@ -46,7 +46,12 @@ export const paymentMachine = setup({
states: { states: {
idle: { idle: {
on: { on: {
PaymentInit: "loadingCachedPlans", PaymentInit: {
target: "loadingCachedPlans",
actions: assign(({ event }) =>
event.payChannel ? { payChannel: event.payChannel } : {},
),
},
}, },
}, },