fix(subscription): recover stepper when a pending checkout is abandoned
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:
Sase Dev
2026-05-13 22:57:13 +00:00
parent 9c05a05a7a
commit 02bf7342a1
5 changed files with 142 additions and 2 deletions

View File

@@ -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,