feat(subscription): separate selection from payment and add issue feedback
Docker Image / Build and Push Docker Image (push) Successful in 2m2s

(cherry picked from commit fe9d31146b)
This commit is contained in:
Codex
2026-07-27 11:35:28 +08:00
parent 9602fdd94d
commit b34d3a3a67
15 changed files with 1339 additions and 74 deletions
+88 -35
View File
@@ -1,12 +1,11 @@
"use client";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { BackButton } from "@/app/_components";
import { Checkbox, MobileShell } from "@/app/_components/core";
import { MobileShell } from "@/app/_components/core";
import { PaymentMethodSelector } from "@/app/_components/payment/payment-method-selector";
import { usePaymentMethodSelection } from "@/app/_hooks/use-payment-method-selection";
import { usePaymentPlanAnalytics } from "@/app/_hooks/use-payment-plan-analytics";
import { AppConstants } from "@/core/app_constants";
import type { PayChannel } from "@/data/schemas/payment";
import type { SubscriptionReturnTo } from "@/lib/navigation/subscription_exit";
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
@@ -22,14 +21,18 @@ import { useUserState } from "@/stores/user/user-context";
import {
SubscriptionCheckoutButton,
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,
@@ -63,6 +66,14 @@ 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,
);
const userState = useUserState();
const hasHydrated = useHasHydrated();
const countryCode = userState.currentUser?.countryCode;
@@ -139,15 +150,48 @@ export function SubscriptionScreen({
const isPaymentBusy =
payment.isCreatingOrder ||
payment.isPollingOrder;
const selectedPlanIsVip = vipOfferPlans.some(
(plan) => plan.id === payment.selectedPlanId,
);
const canActivate =
selectedPlan !== null && payment.agreed && !isPaymentBusy;
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",
@@ -200,8 +244,24 @@ export function SubscriptionScreen({
variant="soft"
analyticsKey="subscription.back"
/>
<button
type="button"
className={styles.paymentIssueButton}
onClick={() => {
setPaymentIssueNotice(null);
setShowPaymentIssueDialog(true);
}}
>
Payment issue?
</button>
</header>
{paymentIssueNotice ? (
<p className={styles.paymentIssueNotice} role="status">
{paymentIssueNotice}
</p>
) : null}
{firstRechargeOffer ? (
<section
className={styles.firstRechargeBanner}
@@ -280,39 +340,32 @@ export function SubscriptionScreen({
/>
</div>
<div className={styles.agreementSlot}>
<Checkbox
checked={payment.agreed}
onChange={(agreed) =>
paymentDispatch({
type: "PaymentAgreementChanged",
agreed,
})
}
label={
<span className={styles.agreementLabel}>
I agree to the{" "}
<a
href={AppConstants.membershipAgreementUrl}
target="_blank"
rel="noreferrer"
className={styles.agreementLink}
>
VIP Membership Benefits Agreement
</a>{" "}
and{" "}
<a
href={AppConstants.autoRenewalAgreementUrl}
target="_blank"
rel="noreferrer"
className={styles.agreementLink}
>
Automatic renewal Agreement
</a>
</span>
<SubscriptionRenewalConfirmationDialog
open={pendingVipPlan !== null}
plan={pendingVipPlan}
onCancel={() => setPendingVipPlanId(null)}
onConfirm={handleConfirmVipPlan}
/>
{showPaymentIssueDialog ? (
<SubscriptionPaymentIssueDialog
open
subscriptionType={
selectedPlan
? selectedPlanIsVip
? "vip"
: "topup"
: subscriptionType
}
planId={selectedPlan?.id ?? null}
orderId={payment.currentOrderId}
payChannel={payment.payChannel}
countryCode={countryCode}
characterId={sourceCharacterSlug}
onClose={() => setShowPaymentIssueDialog(false)}
onSubmitted={setPaymentIssueNotice}
/>
</div>
) : null}
<SubscriptionPaymentSuccessDialog
open={showPaymentSuccessDialog}