393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
"use client";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
import { Checkbox, MobileShell } from "@/app/_components/core";
|
|
import { AppConstants } from "@/core/app_constants";
|
|
import { PendingPaymentOrderStorage } from "@/data/storage";
|
|
import {
|
|
usePaymentDispatch,
|
|
usePaymentState,
|
|
} from "@/stores/payment/payment-context";
|
|
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
|
|
import { ROUTES } from "@/router/routes";
|
|
|
|
import {
|
|
SubscriptionBackLink,
|
|
SubscriptionBanner,
|
|
SubscriptionCheckoutButton,
|
|
SubscriptionCoinsOfferSection,
|
|
SubscriptionPaymentMethod,
|
|
SubscriptionPaymentSuccessDialog,
|
|
SubscriptionPlanCard,
|
|
SubscriptionUserRow,
|
|
SubscriptionVipOfferSection,
|
|
} from "./components";
|
|
import styles from "./components/subscription-screen.module.css";
|
|
import {
|
|
SUBSCRIPTION_BANNER_TITLES,
|
|
isCreditPlan,
|
|
isPlanForType,
|
|
isVipPlan,
|
|
toCoinsOfferPlanView,
|
|
toPlanView,
|
|
toVipOfferPlanView,
|
|
type SubscriptionType,
|
|
} from "./subscription-screen.helpers";
|
|
|
|
const FALLBACK_USERNAME = "User name";
|
|
export type { SubscriptionType } from "./subscription-screen.helpers";
|
|
|
|
export interface SubscriptionScreenProps {
|
|
subscriptionType?: SubscriptionType;
|
|
shouldResumePendingOrder?: boolean;
|
|
returnTo?: "chat" | null;
|
|
}
|
|
|
|
export function SubscriptionScreen({
|
|
subscriptionType = "vip",
|
|
shouldResumePendingOrder = false,
|
|
returnTo = null,
|
|
}: SubscriptionScreenProps) {
|
|
const router = useRouter();
|
|
const user = useUserState();
|
|
const userDispatch = useUserDispatch();
|
|
const payment = usePaymentState();
|
|
const paymentDispatch = usePaymentDispatch();
|
|
const refreshedPaidOrderRef = useRef<string | null>(null);
|
|
const resumedPendingOrderRef = useRef<string | null>(null);
|
|
const successDialogShownOrderRef = useRef<string | null>(null);
|
|
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
|
|
useState(false);
|
|
const isVipSubscription = subscriptionType === "vip";
|
|
|
|
useEffect(() => {
|
|
if (payment.status === "idle") {
|
|
paymentDispatch({ type: "PaymentInit" });
|
|
}
|
|
}, [payment.status, paymentDispatch]);
|
|
|
|
useEffect(() => {
|
|
if (!payment.isPaid || !payment.currentOrderId) return;
|
|
if (refreshedPaidOrderRef.current === payment.currentOrderId) return;
|
|
refreshedPaidOrderRef.current = payment.currentOrderId;
|
|
userDispatch({ type: "UserFetch" });
|
|
}, [payment.currentOrderId, payment.isPaid, userDispatch]);
|
|
|
|
useEffect(() => {
|
|
if (!payment.isPaid || !payment.currentOrderId) return;
|
|
if (successDialogShownOrderRef.current === payment.currentOrderId) return;
|
|
|
|
successDialogShownOrderRef.current = payment.currentOrderId;
|
|
setShowPaymentSuccessDialog(true);
|
|
}, [payment.currentOrderId, payment.isPaid]);
|
|
|
|
useEffect(() => {
|
|
const canInspectPendingOrder =
|
|
payment.status === "ready" ||
|
|
(!shouldResumePendingOrder &&
|
|
(payment.isPollingOrder || payment.isPaid || payment.status === "failed"));
|
|
if (!canInspectPendingOrder) return;
|
|
|
|
let cancelled = false;
|
|
|
|
const handlePendingOrder = async () => {
|
|
const result = await PendingPaymentOrderStorage.getPendingOrderForType(
|
|
subscriptionType,
|
|
);
|
|
if (cancelled || !result.success || result.data === null) return;
|
|
|
|
if (!shouldResumePendingOrder) {
|
|
await PendingPaymentOrderStorage.clearPendingOrder();
|
|
if (
|
|
payment.currentOrderId === result.data.orderId &&
|
|
(payment.isPollingOrder || payment.isPaid || payment.status === "failed")
|
|
) {
|
|
paymentDispatch({ type: "PaymentReset" });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (payment.currentOrderId === result.data.orderId) return;
|
|
if (resumedPendingOrderRef.current === result.data.orderId) return;
|
|
|
|
resumedPendingOrderRef.current = result.data.orderId;
|
|
paymentDispatch({
|
|
type: "PaymentReturned",
|
|
orderId: result.data.orderId,
|
|
});
|
|
};
|
|
|
|
void handlePendingOrder();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
payment.currentOrderId,
|
|
payment.isPaid,
|
|
payment.isPollingOrder,
|
|
payment.status,
|
|
paymentDispatch,
|
|
shouldResumePendingOrder,
|
|
subscriptionType,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!payment.currentOrderId) return;
|
|
if (!payment.isPaid && payment.status !== "failed") return;
|
|
|
|
void PendingPaymentOrderStorage.clearPendingOrder();
|
|
}, [payment.currentOrderId, payment.isPaid, payment.status]);
|
|
|
|
const plans = useMemo(
|
|
() =>
|
|
payment.plans
|
|
.filter((plan) => isPlanForType(plan, subscriptionType))
|
|
.slice(0, 3)
|
|
.map((plan) => toPlanView(plan, subscriptionType)),
|
|
[payment.plans, subscriptionType],
|
|
);
|
|
const vipOfferPlans = useMemo(
|
|
() =>
|
|
payment.plans
|
|
.filter(isVipPlan)
|
|
.slice(0, 3)
|
|
.map(toVipOfferPlanView),
|
|
[payment.plans],
|
|
);
|
|
const directCoinsPlans = useMemo(
|
|
() => payment.plans.filter(isCreditPlan).map(toCoinsOfferPlanView),
|
|
[payment.plans],
|
|
);
|
|
|
|
const selectedPlan =
|
|
(isVipSubscription
|
|
? [...vipOfferPlans, ...directCoinsPlans].find(
|
|
(plan) => plan.id === payment.selectedPlanId,
|
|
)
|
|
: plans.find((plan) => plan.id === payment.selectedPlanId)) ?? null;
|
|
const isPaymentBusy =
|
|
payment.isCreatingOrder ||
|
|
payment.isPollingOrder;
|
|
const canActivate =
|
|
selectedPlan !== null && payment.agreed && !isPaymentBusy;
|
|
|
|
const name = user.currentUser?.username ?? FALLBACK_USERNAME;
|
|
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
|
|
const plansStatusMessage =
|
|
subscriptionType === "voice"
|
|
? "Voice packages are unavailable."
|
|
: "Membership plans are unavailable.";
|
|
const plansAriaLabel =
|
|
subscriptionType === "voice"
|
|
? "Choose a voice package"
|
|
: "Choose a subscription plan";
|
|
|
|
const finishPaymentSuccessClose = () => {
|
|
setShowPaymentSuccessDialog(false);
|
|
paymentDispatch({ type: "PaymentReset" });
|
|
if (returnTo === "chat") {
|
|
router.replace(ROUTES.chat);
|
|
}
|
|
};
|
|
|
|
const handlePaymentSuccessClose = () => {
|
|
finishPaymentSuccessClose();
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isVipSubscription) {
|
|
const firstVipPlanId = vipOfferPlans[0]?.id ?? "";
|
|
const hasSelectedVipPlan = vipOfferPlans.some(
|
|
(plan) => plan.id === payment.selectedPlanId,
|
|
);
|
|
const hasSelectedCoinsPlan = directCoinsPlans.some(
|
|
(plan) => plan.id === payment.selectedPlanId,
|
|
);
|
|
const canSelectPlan =
|
|
firstVipPlanId.length > 0 &&
|
|
(payment.status === "ready" ||
|
|
payment.status === "paid" ||
|
|
payment.status === "failed");
|
|
|
|
if (!canSelectPlan || hasSelectedVipPlan || hasSelectedCoinsPlan) return;
|
|
|
|
paymentDispatch({
|
|
type: "PaymentPlanSelected",
|
|
planId: firstVipPlanId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (payment.isLoadingPlans || payment.plans.length === 0) return;
|
|
if (selectedPlan !== null) return;
|
|
|
|
const nextPlan = payment.plans.find((plan) =>
|
|
isPlanForType(plan, subscriptionType),
|
|
);
|
|
if (!nextPlan) return;
|
|
|
|
paymentDispatch({
|
|
type: "PaymentPlanSelected",
|
|
planId: nextPlan.planId,
|
|
});
|
|
}, [
|
|
payment.isLoadingPlans,
|
|
payment.plans,
|
|
paymentDispatch,
|
|
payment.selectedPlanId,
|
|
payment.status,
|
|
directCoinsPlans,
|
|
isVipSubscription,
|
|
selectedPlan,
|
|
subscriptionType,
|
|
vipOfferPlans,
|
|
]);
|
|
|
|
return (
|
|
<MobileShell>
|
|
<div className={styles.shell}>
|
|
<header className={styles.header}>
|
|
<SubscriptionBackLink className={styles.backSlot} />
|
|
</header>
|
|
|
|
{isVipSubscription ? (
|
|
<div className={styles.offerStack}>
|
|
<SubscriptionVipOfferSection
|
|
plans={vipOfferPlans}
|
|
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
|
|
onSelectPlan={(planId) =>
|
|
paymentDispatch({
|
|
type: "PaymentPlanSelected",
|
|
planId,
|
|
})
|
|
}
|
|
/>
|
|
<SubscriptionCoinsOfferSection
|
|
plans={directCoinsPlans}
|
|
selectedPlanId={payment.selectedPlanId || selectedPlan?.id || null}
|
|
onSelectPlan={(planId) =>
|
|
paymentDispatch({
|
|
type: "PaymentPlanSelected",
|
|
planId,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<section className={styles.userSlot}>
|
|
<SubscriptionUserRow name={name} avatarUrl={avatarUrl} />
|
|
</section>
|
|
|
|
<section className={styles.bannerSlot}>
|
|
<SubscriptionBanner
|
|
title={SUBSCRIPTION_BANNER_TITLES[subscriptionType]}
|
|
/>
|
|
</section>
|
|
|
|
<section className={styles.plansRow} aria-label={plansAriaLabel}>
|
|
{plans.length > 0 ? (
|
|
plans.map((plan, index) => (
|
|
<SubscriptionPlanCard
|
|
key={plan.id}
|
|
plan={plan}
|
|
gradientIndex={index}
|
|
selected={payment.selectedPlanId === plan.id}
|
|
onClick={() =>
|
|
paymentDispatch({
|
|
type: "PaymentPlanSelected",
|
|
planId: plan.id,
|
|
})
|
|
}
|
|
/>
|
|
))
|
|
) : (
|
|
<p className={styles.plansStatus}>
|
|
{payment.isLoadingPlans
|
|
? "Loading membership plans..."
|
|
: payment.errorMessage ?? plansStatusMessage}
|
|
</p>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{/* <p className={styles.autoRenewCaption}>{autoRenewCaption}</p>
|
|
|
|
<section className={styles.benefitsSlot}>
|
|
{subscriptionType === "voice" ? (
|
|
<div className={styles.benefitsPlaceholder} aria-hidden="true" />
|
|
) : (
|
|
<SubscriptionBenefitsCard
|
|
title="VIP Membership Benefits"
|
|
items={VIP_BENEFITS}
|
|
/>
|
|
)}
|
|
</section> */}
|
|
|
|
<section className={styles.paymentMethodSlot}>
|
|
<SubscriptionPaymentMethod
|
|
value={payment.payChannel}
|
|
disabled={isPaymentBusy}
|
|
onChange={(payChannel) =>
|
|
paymentDispatch({
|
|
type: "PaymentPayChannelChanged",
|
|
payChannel,
|
|
})
|
|
}
|
|
/>
|
|
</section>
|
|
|
|
<div className={styles.ctaSlot}>
|
|
<SubscriptionCheckoutButton
|
|
disabled={!canActivate}
|
|
subscriptionType={subscriptionType}
|
|
returnTo={returnTo}
|
|
/>
|
|
</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.privacyPolicyUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={styles.agreementLink}
|
|
>
|
|
VIP Membership Benefits Agreement
|
|
</a>{" "}
|
|
and{" "}
|
|
<a
|
|
href={AppConstants.termsOfServiceUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={styles.agreementLink}
|
|
>
|
|
Automatic renewal Agreement
|
|
</a>
|
|
</span>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<SubscriptionPaymentSuccessDialog
|
|
open={showPaymentSuccessDialog}
|
|
subscriptionType={subscriptionType}
|
|
onClose={handlePaymentSuccessClose}
|
|
/>
|
|
</div>
|
|
</MobileShell>
|
|
);
|
|
}
|