diff --git a/src/app/chat/chat-screen.helpers.ts b/src/app/chat/chat-screen.helpers.ts index b1ad9b24..73d521ff 100644 --- a/src/app/chat/chat-screen.helpers.ts +++ b/src/app/chat/chat-screen.helpers.ts @@ -1,6 +1,7 @@ "use client"; import type { LoginStatus } from "@/data/dto/auth"; +import type { PayChannel } from "@/data/dto/payment"; import { ROUTE_BUILDERS } from "@/router/routes"; 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", { + payChannel, returnTo: "chat", }); } -export function getChatCreditsTopUpSubscriptionUrl(): string { +export function getChatCreditsTopUpSubscriptionUrl( + payChannel: PayChannel = "stripe", +): string { return ROUTE_BUILDERS.subscription("topup", { + payChannel, returnTo: "chat", }); } @@ -51,11 +58,12 @@ export function getInsufficientCreditsSubscriptionType( export function getChatPaywallNavigationUrl( loginStatus: LoginStatus, type: "vip" | "topup" = "vip", + payChannel: PayChannel = "stripe", ): string { const subscriptionUrl = type === "topup" - ? getChatCreditsTopUpSubscriptionUrl() - : getChatPaywallSubscriptionUrl(); + ? getChatCreditsTopUpSubscriptionUrl(payChannel) + : getChatPaywallSubscriptionUrl(payChannel); if (deriveIsGuest(loginStatus)) { return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl); } diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 7f83b3da..e45be419 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -13,6 +13,7 @@ import { recordExternalBrowserPromptShown, resolveExternalBrowserPromptEligibility, } from "@/lib/chat/chat_external_browser"; +import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { MobileShell } from "@/app/_components/core"; @@ -115,10 +116,14 @@ export function ChatScreen() { } function handleMessageLimitUnlock(): void { + const defaultPayChannel = getDefaultPayChannelForCountryCode( + userState.currentUser?.countryCode, + ); router.push( getChatPaywallNavigationUrl( authState.loginStatus, getInsufficientCreditsSubscriptionType(userState.isVip), + defaultPayChannel, ), ); } diff --git a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts b/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts index a524e3d2..a8c3e30a 100644 --- a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts +++ b/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts @@ -15,6 +15,7 @@ import { type PendingChatUnlock, type PendingChatUnlockKind, } from "@/lib/navigation/chat_unlock_session"; +import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; @@ -135,10 +136,13 @@ export function useChatUnlockNavigationFlow({ stage: "payment", }); chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + const defaultPayChannel = getDefaultPayChannelForCountryCode( + userState.currentUser?.countryCode, + ); router.push( ROUTE_BUILDERS.subscription( getInsufficientCreditsSubscriptionType(userState.isVip), - { returnTo: "chat" }, + { payChannel: defaultPayChannel, returnTo: "chat" }, ), ); })(); diff --git a/src/app/sidebar/sidebar-screen.tsx b/src/app/sidebar/sidebar-screen.tsx index f468710e..fc45f542 100644 --- a/src/app/sidebar/sidebar-screen.tsx +++ b/src/app/sidebar/sidebar-screen.tsx @@ -7,7 +7,8 @@ import { useRouter } from "next/navigation"; import { BackButton } from "@/app/_components"; 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 { useUserDispatch, useUserState } from "@/stores/user/user-context"; @@ -20,8 +21,6 @@ import { import styles from "./components/sidebar-screen.module.css"; 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() { const router = useRouter(); @@ -61,6 +60,15 @@ export function SidebarScreen() { const name = user.currentUser?.username ?? FALLBACK_USERNAME; 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 ( @@ -88,9 +96,9 @@ export function SidebarScreen() { onActivateVip={ sidebarState === "vip" ? 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)} /> diff --git a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts index b1933c1f..93ef745e 100644 --- a/src/app/subscription/__tests__/subscription-screen.helpers.test.ts +++ b/src/app/subscription/__tests__/subscription-screen.helpers.test.ts @@ -62,7 +62,7 @@ describe("subscription screen helpers", () => { { id: "coin_1000", coins: 1000, - price: "9.90", + price: "9.9", currency: "US$", }, ]); @@ -98,7 +98,7 @@ describe("subscription screen helpers", () => { ).toMatchObject([ { id: "vip_test", - price: "77.90", + price: "77.9", }, ]); }); @@ -138,7 +138,7 @@ describe("subscription screen helpers", () => { { id: "coin_1000", coins: 1000, - price: "9.90", + price: "9.9", currency: "US$", }, ], @@ -156,7 +156,7 @@ describe("subscription screen helpers", () => { selectedPlan: { id: "coin_1000", coins: 1000, - price: "9.90", + price: "9.9", currency: "US$", }, vipPlans: [], @@ -164,7 +164,7 @@ describe("subscription screen helpers", () => { { id: "coin_1000", coins: 1000, - price: "9.90", + price: "9.9", currency: "US$", }, ], diff --git a/src/app/subscription/subscription-page-client.tsx b/src/app/subscription/subscription-page-client.tsx index f6b1faf9..1ab8b71f 100644 --- a/src/app/subscription/subscription-page-client.tsx +++ b/src/app/subscription/subscription-page-client.tsx @@ -2,6 +2,8 @@ import { useSearchParams } from "next/navigation"; +import type { PayChannel } from "@/data/dto/payment"; + import { SubscriptionScreen, type SubscriptionType, @@ -15,17 +17,23 @@ function toReturnTo(value: string | null): "chat" | null { return value === "chat" ? "chat" : null; } +function toPayChannel(value: string | null): PayChannel { + return value === "ezpay" ? "ezpay" : "stripe"; +} + export function SubscriptionPageClient() { const searchParams = useSearchParams(); const subscriptionType = toSubscriptionType(searchParams.get("type")); const shouldResumePendingOrder = searchParams.get("paymentReturn") === "1"; const returnTo = toReturnTo(searchParams.get("returnTo")); + const initialPayChannel = toPayChannel(searchParams.get("payChannel")); return ( ); } diff --git a/src/app/subscription/subscription-screen.helpers.ts b/src/app/subscription/subscription-screen.helpers.ts index c06e01fe..17a6f0cf 100644 --- a/src/app/subscription/subscription-screen.helpers.ts +++ b/src/app/subscription/subscription-screen.helpers.ts @@ -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 { VipOfferPlanView } from "./components/subscription-vip-offer-section"; 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 { return plan.vipDays !== null; } diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index f20a07b9..ee875b20 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -1,10 +1,10 @@ "use client"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo } from "react"; import { BackButton } from "@/app/_components"; import { Checkbox, MobileShell } from "@/app/_components/core"; import { AppConstants } from "@/core/app_constants"; -import { useUserState } from "@/stores/user/user-context"; +import type { PayChannel } from "@/data/dto/payment"; import { SubscriptionCheckoutButton, @@ -16,7 +16,6 @@ import { import styles from "./components/subscription-screen.module.css"; import { findSelectedSubscriptionPlan, - getDefaultPayChannelForCountryCode, getDefaultSubscriptionPlanId, toCoinsOfferPlanViews, toVipOfferPlanViews, @@ -30,12 +29,14 @@ export interface SubscriptionScreenProps { subscriptionType?: SubscriptionType; shouldResumePendingOrder?: boolean; returnTo?: "chat" | null; + initialPayChannel?: PayChannel; } export function SubscriptionScreen({ subscriptionType = "vip", shouldResumePendingOrder = false, returnTo = null, + initialPayChannel = "stripe", }: SubscriptionScreenProps) { const { payment, @@ -47,9 +48,8 @@ export function SubscriptionScreen({ subscriptionType, shouldResumePendingOrder, returnTo, + initialPayChannel, }); - const user = useUserState(); - const paymentMethodManuallySelectedRef = useRef(false); const canSubscribeVip = subscriptionType === "vip"; const vipOfferPlans = useMemo( @@ -100,28 +100,6 @@ export function SubscriptionScreen({ 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 (
@@ -163,7 +141,6 @@ export function SubscriptionScreen({ value={payment.payChannel} disabled={isPaymentBusy} onChange={(payChannel) => { - paymentMethodManuallySelectedRef.current = true; paymentDispatch({ type: "PaymentPayChannelChanged", payChannel, diff --git a/src/app/subscription/use-subscription-payment-flow.ts b/src/app/subscription/use-subscription-payment-flow.ts index e6167f5d..2a1fad77 100644 --- a/src/app/subscription/use-subscription-payment-flow.ts +++ b/src/app/subscription/use-subscription-payment-flow.ts @@ -16,6 +16,7 @@ import { usePaymentState, } from "@/stores/payment/payment-context"; import { useUserDispatch } from "@/stores/user/user-context"; +import type { PayChannel } from "@/data/dto/payment"; import type { SubscriptionType } from "./subscription-screen.helpers"; @@ -23,12 +24,14 @@ export interface UseSubscriptionPaymentFlowInput { subscriptionType: SubscriptionType; shouldResumePendingOrder: boolean; returnTo: "chat" | null; + initialPayChannel: PayChannel; } export function useSubscriptionPaymentFlow({ subscriptionType, shouldResumePendingOrder, returnTo, + initialPayChannel, }: UseSubscriptionPaymentFlowInput) { const router = useRouter(); const userDispatch = useUserDispatch(); @@ -37,14 +40,37 @@ export function useSubscriptionPaymentFlow({ const refreshedPaidOrderRef = useRef(null); const resumedPendingOrderRef = useRef(null); const successDialogShownOrderRef = useRef(null); + const initialPayChannelAppliedRef = useRef(false); const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = useState(false); useEffect(() => { 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(() => { if (!payment.isPaid || !payment.currentOrderId) return; diff --git a/src/lib/payment/__tests__/pending_payment_order.test.ts b/src/lib/payment/__tests__/pending_payment_order.test.ts new file mode 100644 index 00000000..81ed7627 --- /dev/null +++ b/src/lib/payment/__tests__/pending_payment_order.test.ts @@ -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"); + }); +}); diff --git a/src/lib/payment/default_pay_channel.ts b/src/lib/payment/default_pay_channel.ts new file mode 100644 index 00000000..093a5cdf --- /dev/null +++ b/src/lib/payment/default_pay_channel.ts @@ -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"; +} diff --git a/src/lib/payment/pending_payment_order.ts b/src/lib/payment/pending_payment_order.ts index cce258b5..6e7cd943 100644 --- a/src/lib/payment/pending_payment_order.ts +++ b/src/lib/payment/pending_payment_order.ts @@ -43,10 +43,14 @@ export function clearPendingPaymentOrder(): Promise> { } export function buildPendingPaymentSubscriptionUrl( - order: Pick, + order: Pick< + PendingPaymentOrder, + "payChannel" | "returnTo" | "subscriptionType" + >, ): string { const params = new URLSearchParams({ type: order.subscriptionType, + payChannel: order.payChannel, paymentReturn: "1", }); if (order.returnTo) params.set("returnTo", order.returnTo); diff --git a/src/router/routes.ts b/src/router/routes.ts index 4102614d..7afe4794 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -14,6 +14,8 @@ * error / not-found / page / sidebar-screen / splash-screen)使用 —— 不能整体注释。 */ +import type { PayChannel } from "@/data/dto/payment"; + /** 静态路由字面量 */ export const ROUTES = { root: "/", @@ -35,9 +37,10 @@ export const ROUTE_BUILDERS = { `/chat/image/${encodeURIComponent(messageId)}` as const, subscription: ( type: "vip" | "topup", - options: { returnTo?: "chat" } = {}, + options: { payChannel?: PayChannel; returnTo?: "chat" } = {}, ): `/subscription?${string}` => { const params = new URLSearchParams({ type }); + if (options.payChannel) params.set("payChannel", options.payChannel); if (options.returnTo) params.set("returnTo", options.returnTo); return `${ROUTES.subscription}?${params.toString()}` as const; }, diff --git a/src/stores/payment/payment-events.ts b/src/stores/payment/payment-events.ts index a0d1bcf7..cb2e3c7e 100644 --- a/src/stores/payment/payment-events.ts +++ b/src/stores/payment/payment-events.ts @@ -4,7 +4,7 @@ import type { PayChannel } from "@/data/dto/payment"; export type PaymentEvent = - | { type: "PaymentInit" } + | { type: "PaymentInit"; payChannel?: PayChannel } | { type: "PaymentPlanSelected"; planId: string } | { type: "PaymentPayChannelChanged"; payChannel: PayChannel } | { type: "PaymentAutoRenewChanged"; autoRenew: boolean } diff --git a/src/stores/payment/payment-machine.ts b/src/stores/payment/payment-machine.ts index 8251fc2e..bdeaf037 100644 --- a/src/stores/payment/payment-machine.ts +++ b/src/stores/payment/payment-machine.ts @@ -46,7 +46,12 @@ export const paymentMachine = setup({ states: { idle: { on: { - PaymentInit: "loadingCachedPlans", + PaymentInit: { + target: "loadingCachedPlans", + actions: assign(({ event }) => + event.payChannel ? { payChannel: event.payChannel } : {}, + ), + }, }, },