fix(subscription): recover stepper when a pending checkout is abandoned
Some checks failed
Deploy / Deploy to Production (push) Has been cancelled
Some checks failed
Deploy / Deploy to Production (push) Has been cancelled
A user who picked a plan + brands, clicked the checkout button to create the pending subscription, then closed the tab without paying came back to a frozen "Mevcut Plan · Bekliyor" card with no payment button and no way to edit. Status was "pending", which my conditional treated as an active subscription and routed away from the stepper. Frontend - hasActiveSub narrowed to active|cancelled only. Pending users now see the stepper again. - New effect pre-fills selectedPlanKey, selectedBrandIds, billingPeriod from the pending row on first render and jumps straight to the payment step. - New PendingPaymentBanner at the top of the stepper with "Odemeye devam et" (scrolls + sets step=payment) and "Vazgec" (calls cancel-pending then resets selection). Backend - subscriptions.service.create() auto-expires existing pending rows for the user before inserting a new one — abandoned checkouts no longer accumulate and a fresh attempt with a different plan doesn't 409. - New PATCH /subscriptions/cancel-pending + service method for the banner's explicit cancel action. i18n: subscription.pendingBanner.* (tr/en). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -396,6 +396,35 @@ export function SubscriptionPage() {
|
||||
subscription?.billingPeriod,
|
||||
]);
|
||||
|
||||
// ─── Effect: pre-fill stepper from pending subscription ────────────────────
|
||||
const hasPrefilledFromPendingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!subscription || subscription.status !== "pending") return;
|
||||
if (hasPrefilledFromPendingRef.current) return;
|
||||
if (selectedPlanKey) return; // user already started fresh selection — don't overwrite
|
||||
const planKey = subscription.plan?.name ? planNameToKey(subscription.plan.name) : null;
|
||||
if (!planKey) return;
|
||||
hasPrefilledFromPendingRef.current = true;
|
||||
setSelectedPlanKey(planKey);
|
||||
setSelectedBrandIds((subscription.brands ?? []).map((b) => b.brandId));
|
||||
setBillingPeriod(subscription.billingPeriod === "yearly" ? "yearly" : "monthly");
|
||||
setStep("payment");
|
||||
}, [subscription, selectedPlanKey]);
|
||||
|
||||
const cancelPendingMutation = useMutation({
|
||||
mutationFn: () => api.patch("/subscriptions/cancel-pending"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
hasPrefilledFromPendingRef.current = false;
|
||||
setSelectedPlanKey(null);
|
||||
setSelectedBrandIds([]);
|
||||
setStep("plan");
|
||||
toast.success(t("subscription.pendingBanner.cancelled"));
|
||||
capture("pending_subscription_cancelled");
|
||||
},
|
||||
onError: () => toast.error(t("errors.generic")),
|
||||
});
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
function handleSelectPlan(planKey: string) {
|
||||
if (planKey === selectedPlanKey) return; // idempotent — no brand wipe
|
||||
@@ -505,8 +534,11 @@ export function SubscriptionPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Stepper visible for: no sub, expired, trial, and pending (abandoned checkout).
|
||||
// Active subscription card shown only for active/cancelled.
|
||||
const hasActiveSub =
|
||||
subscription && subscription.status !== "expired" && subscription.status !== "trial";
|
||||
!!subscription && (subscription.status === "active" || subscription.status === "cancelled");
|
||||
const isPending = subscription?.status === "pending";
|
||||
const showStepper = !hasActiveSub || subscription?.status === "trial";
|
||||
|
||||
return (
|
||||
@@ -516,7 +548,7 @@ export function SubscriptionPage() {
|
||||
<p className="text-sm text-muted-foreground">{t("subscription.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
{subscription && !(eligibleForTrial && subscription.status === "trial") && (
|
||||
{subscription && hasActiveSub && (
|
||||
<ActiveSubscriptionCard
|
||||
subscription={subscription}
|
||||
statusVariants={statusVariants}
|
||||
@@ -549,6 +581,23 @@ export function SubscriptionPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPending && subscription && (
|
||||
<PendingPaymentBanner
|
||||
planName={subscription.plan?.name ?? ""}
|
||||
brandCount={subscription.brands?.length ?? 0}
|
||||
isFullPlan={
|
||||
subscription.plan?.name ? planNameToKey(subscription.plan.name) === "full" : false
|
||||
}
|
||||
onResume={() => {
|
||||
capture("pending_subscription_resumed");
|
||||
document.getElementById("checkout-flow")?.scrollIntoView({ behavior: "smooth" });
|
||||
setStep("payment");
|
||||
}}
|
||||
onCancel={() => cancelPendingMutation.mutate()}
|
||||
cancelling={cancelPendingMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStepper && (
|
||||
<div id="checkout-flow" className="space-y-6">
|
||||
<StepIndicator
|
||||
@@ -1375,6 +1424,62 @@ function TrialCtaCard({ onStartTrial, pending }: { onStartTrial: () => void; pen
|
||||
);
|
||||
}
|
||||
|
||||
function PendingPaymentBanner({
|
||||
planName,
|
||||
brandCount,
|
||||
isFullPlan,
|
||||
onResume,
|
||||
onCancel,
|
||||
cancelling,
|
||||
}: {
|
||||
planName: string;
|
||||
brandCount: number;
|
||||
isFullPlan: boolean;
|
||||
onResume: () => void;
|
||||
onCancel: () => void;
|
||||
cancelling: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const brandsLabel = isFullPlan
|
||||
? t("subscription.allBrandsSelected")
|
||||
: `${brandCount} ${t("subscription.brandsSelected")}`;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-fade-in-up flex flex-col gap-3 rounded-2xl border border-blue-200 bg-blue-50 px-5 py-4 dark:border-blue-900/60 dark:bg-blue-950/30 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-blue-500/15 text-blue-600 dark:text-blue-300">
|
||||
<Clock className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-900 dark:text-blue-100">
|
||||
{t("subscription.pendingBanner.title")}
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
{t("subscription.pendingBanner.description", { plan: planName, brands: brandsLabel })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={cancelling}
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-100 dark:border-blue-800 dark:text-blue-300 dark:hover:bg-blue-900/50"
|
||||
>
|
||||
{cancelling ? t("common.loading") : t("subscription.pendingBanner.cancelCta")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onResume}>
|
||||
{t("subscription.pendingBanner.resumeCta")}
|
||||
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrialUrgencyBanner({
|
||||
daysRemaining,
|
||||
onCta,
|
||||
|
||||
Reference in New Issue
Block a user