feat(payment): confirm renewal only at checkout
This commit is contained in:
@@ -51,6 +51,7 @@ const mocks = vi.hoisted(() => {
|
||||
commercialOffer: null,
|
||||
selectedPlanId: "vip_monthly",
|
||||
payChannel: "stripe" as const,
|
||||
autoRenew: true,
|
||||
agreed: true,
|
||||
currentOrderId: null,
|
||||
isCreatingOrder: false,
|
||||
@@ -100,7 +101,9 @@ vi.mock("@/hooks/use-has-hydrated", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/user/user-context", () => ({
|
||||
useUserState: () => ({ currentUser: { countryCode: "ID" } }),
|
||||
useUserState: () => ({
|
||||
currentUser: { id: "user-renewal-1", countryCode: "ID" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/providers/character-catalog-provider", () => ({
|
||||
@@ -164,26 +167,25 @@ vi.mock("../components", () => ({
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
SubscriptionCheckoutButton: ({ disabled }: { disabled: boolean }) => (
|
||||
<button type="button" data-testid="checkout" disabled={disabled}>
|
||||
SubscriptionCheckoutButton: ({
|
||||
disabled,
|
||||
renewalPlan,
|
||||
renewalConsentSubjectId,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
renewalPlan: { id: string } | null;
|
||||
renewalConsentSubjectId: string | null;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="checkout"
|
||||
data-renewal-plan={renewalPlan?.id ?? ""}
|
||||
data-renewal-subject={renewalConsentSubjectId ?? ""}
|
||||
disabled={disabled}
|
||||
>
|
||||
Pay and Top Up
|
||||
</button>
|
||||
),
|
||||
SubscriptionRenewalConfirmationDialog: ({
|
||||
open,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog">
|
||||
<button type="button" onClick={onCancel}>Cancel</button>
|
||||
<button type="button" onClick={onConfirm}>Confirm</button>
|
||||
</div>
|
||||
) : null,
|
||||
SubscriptionPaymentIssueDialog: () => null,
|
||||
SubscriptionPaymentSuccessDialog: () => null,
|
||||
}));
|
||||
@@ -218,66 +220,30 @@ describe("SubscriptionScreen payment selection flow", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("confirms VIP selection before enabling the separate checkout button", () => {
|
||||
it("allows immediate checkout and changes VIP plans without opening renewal confirmation", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
const checkout = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="checkout"]',
|
||||
);
|
||||
expect(checkout?.disabled).toBe(true);
|
||||
expect(checkout?.disabled).toBe(false);
|
||||
expect(checkout?.dataset.renewalPlan).toBe("vip_monthly");
|
||||
expect(checkout?.dataset.renewalSubject).toBe("user-renewal-1");
|
||||
|
||||
act(() => clickButton(container, "vip_monthly"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
|
||||
);
|
||||
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentPlanSelected",
|
||||
planId: "vip_monthly",
|
||||
});
|
||||
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "PaymentCreateOrderSubmitted" }),
|
||||
);
|
||||
expect(checkout?.disabled).toBe(false);
|
||||
|
||||
act(() => clickButton(container, "vip_monthly"));
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
act(() => clickButton(container, "vip_quarterly"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
|
||||
});
|
||||
|
||||
it("keeps the original selection when VIP confirmation is cancelled", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => clickButton(container, "vip_quarterly"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
act(() => clickButton(container, "Cancel"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.payment.selectedPlanId).toBe("vip_monthly");
|
||||
expect(mocks.paymentDispatch).not.toHaveBeenCalledWith({
|
||||
expect(mocks.paymentDispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentPlanSelected",
|
||||
planId: "vip_quarterly",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires VIP confirmation again after the page is remounted", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => clickButton(container, "vip_monthly"));
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => clickButton(container, "vip_monthly"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("selects a coin package without showing the renewal dialog", () => {
|
||||
act(() => root.render(<SubscriptionScreen sourceCharacterSlug="elio" />));
|
||||
act(() => clickButton(container, "coin_1000"));
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
import {
|
||||
canCheckoutSubscriptionPlan,
|
||||
findSelectedSubscriptionPlan,
|
||||
getDefaultSubscriptionPlanId,
|
||||
getFirstRechargeOfferView,
|
||||
requiresVipPlanConfirmation,
|
||||
toCoinsOfferPlanViews,
|
||||
toVipOfferPlanViews,
|
||||
} from "../subscription-screen.helpers";
|
||||
@@ -280,85 +278,4 @@ describe("subscription screen helpers", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("requires confirmation for each unconfirmed VIP plan", () => {
|
||||
const vipPlans = [
|
||||
{
|
||||
id: "vip_monthly",
|
||||
title: "Monthly",
|
||||
price: "19.90",
|
||||
currency: "usd",
|
||||
originalPrice: "",
|
||||
},
|
||||
{
|
||||
id: "vip_quarterly",
|
||||
title: "Quarterly",
|
||||
price: "49.90",
|
||||
currency: "usd",
|
||||
originalPrice: "",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
requiresVipPlanConfirmation({
|
||||
planId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresVipPlanConfirmation({
|
||||
planId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(["vip_monthly"]),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
requiresVipPlanConfirmation({
|
||||
planId: "coin_1000",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows checkout only after the selected VIP plan is confirmed", () => {
|
||||
const vipPlans = [
|
||||
{
|
||||
id: "vip_monthly",
|
||||
title: "Monthly",
|
||||
price: "19.90",
|
||||
currency: "usd",
|
||||
originalPrice: "",
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "vip_monthly",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(["vip_monthly"]),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "coin_1000",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: "",
|
||||
vipPlans,
|
||||
confirmedVipPlanIds: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
resetLaunchState: vi.fn(),
|
||||
payment: {
|
||||
selectedPlanId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
payChannel: "stripe" as "stripe" | "ezpay",
|
||||
commercialOfferId: null,
|
||||
chatActionId: null,
|
||||
currentOrderId: null,
|
||||
errorMessage: null,
|
||||
isCreatingOrder: false,
|
||||
isPollingOrder: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/payment/payment-context", () => ({
|
||||
usePaymentState: () => mocks.payment,
|
||||
usePaymentDispatch: () => mocks.dispatch,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
|
||||
usePaymentLaunchFlow: () => ({
|
||||
resetLaunchState: mocks.resetLaunchState,
|
||||
launch: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_components/payment/payment-launch-dialogs", () => ({
|
||||
PaymentLaunchDialogs: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_components/payment/external-browser-checkout-button", () => ({
|
||||
ExternalBrowserCheckoutButton: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../subscription-cta-button", () => ({
|
||||
SubscriptionCtaButton: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../subscription-renewal-confirmation-dialog", () => ({
|
||||
SubscriptionRenewalConfirmationDialog: ({
|
||||
open,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog">
|
||||
<button type="button" onClick={onCancel}>Cancel</button>
|
||||
<button type="button" onClick={onConfirm}>Confirm</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { SubscriptionCheckoutButton } from "../subscription-checkout-button";
|
||||
|
||||
const renewalPlan = {
|
||||
id: "vip_monthly",
|
||||
title: "Monthly",
|
||||
price: "19.90",
|
||||
currency: "usd",
|
||||
originalPrice: "39.90",
|
||||
};
|
||||
|
||||
describe("SubscriptionCheckoutButton renewal confirmation", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
localStorage.clear();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.resetLaunchState.mockClear();
|
||||
mocks.payment.selectedPlanId = "vip_monthly";
|
||||
mocks.payment.autoRenew = true;
|
||||
mocks.payment.payChannel = "stripe";
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("shows confirmation on the first auto-renewing VIP checkout and resumes after confirm", () => {
|
||||
renderButton(root, "user-1");
|
||||
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not ask the same user again after confirmation", () => {
|
||||
renderButton(root, "user-1");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.resetLaunchState.mockClear();
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
renderButton(root, "user-1");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps asking after cancel and isolates acknowledgement by user", () => {
|
||||
renderButton(root, "user-1");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
act(() => clickButton(container, "Cancel"));
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
|
||||
act(() => clickButton(container, "Confirm"));
|
||||
mocks.dispatch.mockClear();
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
renderButton(root, "user-2");
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(mocks.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips confirmation for a non-renewing purchase", () => {
|
||||
mocks.payment.autoRenew = false;
|
||||
renderButton(root, "user-1", null);
|
||||
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips confirmation for one-time EzPay membership checkout", () => {
|
||||
mocks.payment.payChannel = "ezpay";
|
||||
renderButton(root, "user-1");
|
||||
|
||||
act(() => clickButton(container, "Pay and Top Up"));
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: "PaymentCreateOrderSubmitted",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function renderButton(
|
||||
root: Root,
|
||||
renewalConsentSubjectId: string,
|
||||
plan: typeof renewalPlan | null = renewalPlan,
|
||||
): void {
|
||||
act(() =>
|
||||
root.render(
|
||||
<SubscriptionCheckoutButton
|
||||
subscriptionType="vip"
|
||||
sourceCharacterSlug="elio"
|
||||
renewalPlan={plan}
|
||||
renewalConsentSubjectId={renewalConsentSubjectId}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function clickButton(root: ParentNode, label: string): void {
|
||||
const button = Array.from(root.querySelectorAll("button")).find(
|
||||
(item) => item.textContent?.trim() === label,
|
||||
);
|
||||
if (!button) throw new Error(`Expected ${label} button`);
|
||||
button.click();
|
||||
}
|
||||
@@ -71,7 +71,14 @@ describe("subscription payment dialogs", () => {
|
||||
expect(dialog?.textContent).toContain("Automatic Renewal Confirmation");
|
||||
expect(dialog?.textContent).toContain("Monthly");
|
||||
expect(dialog?.textContent).toContain("19.90 usd");
|
||||
expect(dialog?.querySelectorAll("a")).toHaveLength(2);
|
||||
expect(
|
||||
Array.from(dialog?.querySelectorAll("a") ?? []).map((link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual([
|
||||
"/legal/vip-membership-benefits.html",
|
||||
"/legal/automatic-renewal.html",
|
||||
]);
|
||||
|
||||
act(() => clickButton(dialog, "Confirm"));
|
||||
expect(onConfirm).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
*
|
||||
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { usePaymentLaunchFlow } from "@/app/_hooks/use-payment-launch-flow";
|
||||
import { PaymentLaunchDialogs } from "@/app/_components/payment/payment-launch-dialogs";
|
||||
import { ExternalBrowserCheckoutButton } from "@/app/_components/payment/external-browser-checkout-button";
|
||||
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
|
||||
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
|
||||
import {
|
||||
hasAutomaticRenewalAcknowledgement,
|
||||
rememberAutomaticRenewalAcknowledgement,
|
||||
} from "@/lib/payment/automatic_renewal_acknowledgement";
|
||||
import {
|
||||
usePaymentDispatch,
|
||||
usePaymentState,
|
||||
@@ -16,15 +22,19 @@ import {
|
||||
import { Logger } from "@/utils/logger";
|
||||
|
||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||
import { SubscriptionRenewalConfirmationDialog } from "./subscription-renewal-confirmation-dialog";
|
||||
import type { VipOfferPlanView } from "./subscription-vip-offer-section";
|
||||
|
||||
const log = new Logger("SubscriptionCheckoutButton");
|
||||
|
||||
export interface SubscriptionCheckoutButtonProps {
|
||||
/** 是否可用(未选套餐或 VIP 套餐尚未确认自动续费时为 false) */
|
||||
/** 是否可用(未选套餐、缺少角色来源或支付处理中为 false) */
|
||||
disabled?: boolean;
|
||||
subscriptionType: "vip" | "topup";
|
||||
returnTo?: SubscriptionReturnTo;
|
||||
sourceCharacterSlug?: string;
|
||||
renewalPlan?: VipOfferPlanView | null;
|
||||
renewalConsentSubjectId?: string | null;
|
||||
}
|
||||
|
||||
export function SubscriptionCheckoutButton({
|
||||
@@ -32,7 +42,11 @@ export function SubscriptionCheckoutButton({
|
||||
subscriptionType,
|
||||
returnTo = null,
|
||||
sourceCharacterSlug = DEFAULT_CHARACTER_SLUG,
|
||||
renewalPlan = null,
|
||||
renewalConsentSubjectId = null,
|
||||
}: SubscriptionCheckoutButtonProps) {
|
||||
const [showRenewalConfirmation, setShowRenewalConfirmation] =
|
||||
useState(false);
|
||||
const payment = usePaymentState();
|
||||
const paymentDispatch = usePaymentDispatch();
|
||||
const paymentLaunch = usePaymentLaunchFlow({
|
||||
@@ -53,12 +67,32 @@ export function SubscriptionCheckoutButton({
|
||||
? "Creating order..."
|
||||
: readyLabel;
|
||||
|
||||
const handleClick = () => {
|
||||
const startCheckout = () => {
|
||||
if (disabled || isLoading) return;
|
||||
paymentLaunch.resetLaunchState();
|
||||
paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || isLoading) return;
|
||||
if (
|
||||
payment.payChannel === "stripe" &&
|
||||
payment.autoRenew &&
|
||||
renewalPlan !== null &&
|
||||
!hasAutomaticRenewalAcknowledgement(renewalConsentSubjectId)
|
||||
) {
|
||||
setShowRenewalConfirmation(true);
|
||||
return;
|
||||
}
|
||||
startCheckout();
|
||||
};
|
||||
|
||||
const handleRenewalConfirm = () => {
|
||||
rememberAutomaticRenewalAcknowledgement(renewalConsentSubjectId);
|
||||
setShowRenewalConfirmation(false);
|
||||
startCheckout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubscriptionCtaButton
|
||||
@@ -105,6 +139,12 @@ export function SubscriptionCheckoutButton({
|
||||
ezpayDescription="Your order has been created. Continue to the secure payment page to finish."
|
||||
launch={paymentLaunch}
|
||||
/>
|
||||
<SubscriptionRenewalConfirmationDialog
|
||||
open={showRenewalConfirmation}
|
||||
plan={renewalPlan}
|
||||
onCancel={() => setShowRenewalConfirmation(false)}
|
||||
onConfirm={handleRenewalConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,30 +105,6 @@ export function findSelectedSubscriptionPlan(input: {
|
||||
return plans.find((plan) => plan.id === input.selectedPlanId) ?? null;
|
||||
}
|
||||
|
||||
export function requiresVipPlanConfirmation(input: {
|
||||
planId: string;
|
||||
vipPlans: readonly VipOfferPlanView[];
|
||||
confirmedVipPlanIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return (
|
||||
input.vipPlans.some((plan) => plan.id === input.planId) &&
|
||||
!input.confirmedVipPlanIds.has(input.planId)
|
||||
);
|
||||
}
|
||||
|
||||
export function canCheckoutSubscriptionPlan(input: {
|
||||
selectedPlanId: string;
|
||||
vipPlans: readonly VipOfferPlanView[];
|
||||
confirmedVipPlanIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!input.selectedPlanId) return false;
|
||||
return !requiresVipPlanConfirmation({
|
||||
planId: input.selectedPlanId,
|
||||
vipPlans: input.vipPlans,
|
||||
confirmedVipPlanIds: input.confirmedVipPlanIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDefaultSubscriptionPlanId(input: {
|
||||
canSubscribeVip: boolean;
|
||||
selectedPlanId: string;
|
||||
|
||||
@@ -24,16 +24,13 @@ import {
|
||||
SubscriptionCoinsOfferSection,
|
||||
SubscriptionPaymentIssueDialog,
|
||||
SubscriptionPaymentSuccessDialog,
|
||||
SubscriptionRenewalConfirmationDialog,
|
||||
SubscriptionVipOfferSection,
|
||||
} from "./components";
|
||||
import styles from "./components/subscription-screen.module.css";
|
||||
import {
|
||||
canCheckoutSubscriptionPlan,
|
||||
findSelectedSubscriptionPlan,
|
||||
getFirstRechargeOfferView,
|
||||
getDefaultSubscriptionPlanId,
|
||||
requiresVipPlanConfirmation,
|
||||
toCoinsOfferPlanViews,
|
||||
toVipOfferPlanViews,
|
||||
type SubscriptionType,
|
||||
@@ -69,10 +66,6 @@ export function SubscriptionScreen({
|
||||
resumeOrderId = null,
|
||||
chatActionId = null,
|
||||
}: SubscriptionScreenProps) {
|
||||
const [confirmedVipPlanIds, setConfirmedVipPlanIds] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set());
|
||||
const [pendingVipPlanId, setPendingVipPlanId] = useState<string | null>(null);
|
||||
const [showPaymentIssueDialog, setShowPaymentIssueDialog] = useState(false);
|
||||
const [paymentIssueNotice, setPaymentIssueNotice] = useState<string | null>(
|
||||
null,
|
||||
@@ -156,49 +149,20 @@ export function SubscriptionScreen({
|
||||
const isPaymentBusy =
|
||||
payment.isCreatingOrder ||
|
||||
payment.isPollingOrder;
|
||||
const selectedPlanIsVip = vipOfferPlans.some(
|
||||
(plan) => plan.id === payment.selectedPlanId,
|
||||
);
|
||||
const selectedVipPlan =
|
||||
vipOfferPlans.find((plan) => plan.id === payment.selectedPlanId) ?? null;
|
||||
const selectedPlanIsVip = selectedVipPlan !== null;
|
||||
const canActivate =
|
||||
sourceCharacter !== null &&
|
||||
selectedPlan !== null &&
|
||||
canCheckoutSubscriptionPlan({
|
||||
selectedPlanId: payment.selectedPlanId,
|
||||
vipPlans: vipOfferPlans,
|
||||
confirmedVipPlanIds,
|
||||
}) &&
|
||||
!isPaymentBusy;
|
||||
const pendingVipPlan =
|
||||
vipOfferPlans.find((plan) => plan.id === pendingVipPlanId) ?? null;
|
||||
|
||||
const handleSelectPlan = (planId: string) => {
|
||||
const plan = payment.plans.find((item) => item.planId === planId);
|
||||
if (plan) behaviorAnalytics.planClick(plan, analyticsContext);
|
||||
if (
|
||||
requiresVipPlanConfirmation({
|
||||
planId,
|
||||
vipPlans: vipOfferPlans,
|
||||
confirmedVipPlanIds,
|
||||
})
|
||||
) {
|
||||
setPendingVipPlanId(planId);
|
||||
return;
|
||||
}
|
||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||
};
|
||||
|
||||
const handleConfirmVipPlan = () => {
|
||||
if (!pendingVipPlanId) return;
|
||||
const planId = pendingVipPlanId;
|
||||
setConfirmedVipPlanIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(planId);
|
||||
return next;
|
||||
});
|
||||
paymentDispatch({ type: "PaymentPlanSelected", planId });
|
||||
setPendingVipPlanId(null);
|
||||
};
|
||||
|
||||
const handlePaymentMethodChange = (payChannel: PayChannel) => {
|
||||
paymentDispatch({
|
||||
type: "PaymentPayChannelChanged",
|
||||
@@ -338,16 +302,11 @@ export function SubscriptionScreen({
|
||||
subscriptionType={subscriptionType}
|
||||
returnTo={returnTo}
|
||||
sourceCharacterSlug={sourceCharacter?.slug ?? DEFAULT_CHARACTER_SLUG}
|
||||
renewalPlan={selectedVipPlan}
|
||||
renewalConsentSubjectId={userState.currentUser?.id || null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubscriptionRenewalConfirmationDialog
|
||||
open={pendingVipPlan !== null}
|
||||
plan={pendingVipPlan}
|
||||
onCancel={() => setPendingVipPlanId(null)}
|
||||
onConfirm={handleConfirmVipPlan}
|
||||
/>
|
||||
|
||||
{showPaymentIssueDialog ? (
|
||||
<SubscriptionPaymentIssueDialog
|
||||
open
|
||||
|
||||
@@ -13,11 +13,11 @@ export class AppConstants {
|
||||
|
||||
/** 会员服务协议文档链接 */
|
||||
static readonly membershipAgreementUrl =
|
||||
"https://www.banlv-ai.com/cozsweet/membership-agreement.html";
|
||||
"/legal/vip-membership-benefits.html";
|
||||
|
||||
/** 自动续费服务协议文档链接 */
|
||||
static readonly autoRenewalAgreementUrl =
|
||||
"https://www.banlv-ai.com/cozsweet/auto-renewal-agreement.html";
|
||||
"/legal/automatic-renewal.html";
|
||||
|
||||
/** 开发环境 APP 标题 */
|
||||
static readonly appTitleDevelopment = "Develop";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const AUTOMATIC_RENEWAL_AGREEMENT_VERSION = "2026-07-29";
|
||||
|
||||
const STORAGE_PREFIX = "cozsweet.automatic-renewal-acknowledged";
|
||||
|
||||
function storageKey(subjectId: string | null | undefined): string | null {
|
||||
const normalized = subjectId?.trim();
|
||||
if (!normalized) return null;
|
||||
return `${STORAGE_PREFIX}:${AUTOMATIC_RENEWAL_AGREEMENT_VERSION}:${encodeURIComponent(normalized)}`;
|
||||
}
|
||||
|
||||
export function hasAutomaticRenewalAcknowledgement(
|
||||
subjectId: string | null | undefined,
|
||||
): boolean {
|
||||
const key = storageKey(subjectId);
|
||||
if (!key || typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(key) === "confirmed";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberAutomaticRenewalAcknowledgement(
|
||||
subjectId: string | null | undefined,
|
||||
): void {
|
||||
const key = storageKey(subjectId);
|
||||
if (!key || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(key, "confirmed");
|
||||
} catch {
|
||||
// Storage can be unavailable in privacy-restricted in-app browsers.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user