feat(web): subscription — radiogroup plan picker, trial stuck guard, stale-brand cleanup, scoped LS

- PlanGrid now wraps cards as a real radiogroup with role=radio + aria-checked +
  focus-visible ring (was aria-pressed on plain buttons)
- Adds ProvisioningStuckHint: at 30s in trial provisioning we surface a
  contact-support + retry escape so users aren't trapped on a spinner
- PaymentMethodSection strips stale brand IDs against the live /brands query
  before Stripe checkout (would have 4xx'd silently)
- trial-banner dismissal LS key now scoped to userId + endDate (was endDate
  only — two users on the same browser could leak state)
- Sticky CTA disabled state gains a hint title + aria-disabled + opacity
- Brand-selector Suspense gets an 8s slow-load fallback with reload affordance
- OnboardingCompletedView shows the actual subscription plan name (was hard-
  coded "Full Paket")
- Picks up sase-pending-period stashed by the public pricing toggle
This commit is contained in:
2026-05-31 23:04:20 +03:00
parent eb8d8c2dee
commit 611b41c793
3 changed files with 152 additions and 14 deletions

View File

@@ -28,7 +28,8 @@
"perMonth": "/mo",
"perYear": "/yr",
"login": "Log In",
"register": "Sign Up"
"register": "Sign Up",
"reload": "Reload page"
},
"nav": {
"dashboard": "Dashboard",
@@ -195,8 +196,10 @@
"stickyCta": {
"toBrands": "Choose brands",
"toPayment": "Go to payment",
"selectBrands": "Select {count} more brand(s)"
"selectBrands": "Select {count} more brand(s)",
"disabledHint": "Select {count} more brand(s) to continue"
},
"brandSelectorSlow": "Brand list is taking longer than usual. Reload the page if you want to retry.",
"noSubscription": "You don't have an active subscription.",
"choosePlan": "Choose Plan",
"subscribe": "Subscribe",

View File

@@ -28,7 +28,8 @@
"perMonth": "/ay",
"perYear": "/yıl",
"login": "Giriş Yap",
"register": "Kayıt Ol"
"register": "Kayıt Ol",
"reload": "Sayfayı yenile"
},
"nav": {
"dashboard": "Gösterge Paneli",
@@ -195,8 +196,10 @@
"stickyCta": {
"toBrands": "Markaları seç",
"toPayment": "Ödeme yöntemine geç",
"selectBrands": "{count} marka daha seç"
"selectBrands": "{count} marka daha seç",
"disabledHint": "Devam etmek için {count} marka daha seç"
},
"brandSelectorSlow": "Marka listesi her zamankinden geç yükleniyor. Yeniden denemek istersen sayfayı yenileyebilirsin.",
"noSubscription": "Aktif aboneliğiniz yok.",
"choosePlan": "Plan Seç",
"subscribe": "Abone Ol",

View File

@@ -2,6 +2,7 @@ import { CredibilityStrip } from "@/components/credibility-strip";
import { StripeCheckoutButton } from "@/components/payment/stripe-checkout-button";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { useTranslation } from "@/lib/i18n";
import { BRAND_SKELETON_KEYS } from "@/lib/keys";
@@ -70,6 +71,42 @@ function BrandSelectorFallback() {
);
}
// If the dynamic-import for BrandSelector hangs past 8s, the skeleton on its
// own looks identical to a stalled UI. Surface a reload affordance so users
// can recover without thinking they're stuck.
function BrandSelectorFallbackWithTimeout() {
const [slow, setSlow] = useState(false);
useEffect(() => {
const id = setTimeout(() => setSlow(true), 8000);
return () => clearTimeout(id);
}, []);
return (
<div className="space-y-3">
<BrandSelectorFallback />
{slow && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-muted/40 p-3 text-center">
<p className="text-xs leading-5 text-muted-foreground">
<SlowLoadHint />
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
<SlowLoadRetryLabel />
</Button>
</div>
)}
</div>
);
}
function SlowLoadHint() {
const { t } = useTranslation();
return <>{t("subscription.brandSelectorSlow")}</>;
}
function SlowLoadRetryLabel() {
const { t } = useTranslation();
return <>{t("common.reload")}</>;
}
interface SubscriptionSearch {
welcome?: string;
ref?: string | null;
@@ -174,11 +211,20 @@ function getDowngradePlan(currentPlanKey: string) {
type CheckoutStep = "plan" | "brands" | "payment" | "confirmation";
// LocalStorage key scoped to user + trial end-date — prevents a second user
// on the same browser from inheriting the first user's dismissal, and lets a
// re-issued trial re-show the banner (different endDate, different key).
function trialBannerKey(userId: string | null | undefined, endDate: string | null | undefined) {
return `trialBannerDismissed-${userId ?? "anon"}-${endDate ?? "none"}`;
}
export function SubscriptionPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const search = Route.useSearch();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
// ─── Search-param-driven flags (read once) ─────────────────────────────────
const [welcome] = useState(() => search.welcome === "1");
@@ -379,9 +425,9 @@ export function SubscriptionPage() {
useEffect(() => {
if (!subscription?.endDate) return;
const key = `trialBannerDismissed-${subscription.endDate}`;
const key = trialBannerKey(userId, subscription.endDate);
if (localStorage.getItem(key) === "true") setTrialBannerDismissed(true);
}, [subscription?.endDate]);
}, [subscription?.endDate, userId]);
useEffect(() => {
if (
@@ -432,6 +478,13 @@ export function SubscriptionPage() {
if (!planKey || !plans.some((p) => p.key === planKey)) return;
hasPreselectedRef.current = true;
localStorage.removeItem(PENDING_PLAN_KEY);
// Period chosen on the public pricing toggle (Phase 1) — apply if present.
const storedPeriod =
typeof window !== "undefined" ? localStorage.getItem("sase-pending-period") : null;
if (storedPeriod === "yearly" || storedPeriod === "monthly") {
setBillingPeriod(storedPeriod);
localStorage.removeItem("sase-pending-period");
}
capture("plan_selected", { plan: planKey, source: "marketing_preselect" });
setSelectedPlanKey(planKey);
setSelectedBrandIds([]);
@@ -519,7 +572,7 @@ export function SubscriptionPage() {
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
localStorage.setItem(`trialBannerDismissed-${subscription?.endDate}`, "true");
localStorage.setItem(trialBannerKey(userId, subscription?.endDate), "true");
setTrialBannerDismissed(true);
}
@@ -691,7 +744,7 @@ export function SubscriptionPage() {
count: selectedPlan?.brandLimit ?? 0,
})}
</p>
<Suspense fallback={<BrandSelectorFallback />}>
<Suspense fallback={<BrandSelectorFallbackWithTimeout />}>
<BrandSelector
maxBrands={selectedPlan?.brandLimit ?? 1}
selectedBrandIds={selectedBrandIds}
@@ -717,6 +770,7 @@ export function SubscriptionPage() {
period={billingPeriod}
brandIds={selectedBrandIds}
totalAmount={totalAmount}
onStaleBrandsFiltered={setSelectedBrandIds}
/>
</StepCard>
)}
@@ -1040,7 +1094,11 @@ export function PlanGrid({
}) {
const { t } = useTranslation();
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div
role="radiogroup"
aria-label={t("subscription.steps.plan.title")}
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{plans.map((plan) => {
const isSelected = selectedPlanKey === plan.key;
const price = billingPeriod === "monthly" ? plan.priceMonthly : plan.priceYearly;
@@ -1051,9 +1109,12 @@ export function PlanGrid({
key={plan.key}
data-faro-user-action-name={`select-plan-${plan.key}`}
onClick={() => !isCurrentPlan && onSelect(plan.key)}
aria-pressed={isSelected}
// biome-ignore lint/a11y/useSemanticElements: rich card UI; a native <input type="radio"> would force us to rebuild the visuals while losing focus/keyboard behavior we already get from <button>.
role="radio"
aria-checked={isSelected}
aria-label={t(`subscription.plans.${plan.key}.name`)}
disabled={isCurrentPlan}
className={`relative flex flex-col rounded-2xl border bg-background p-5 text-left transition-all ${
className={`relative flex flex-col rounded-2xl border bg-background p-5 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isCurrentPlan
? "cursor-not-allowed border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-950/20"
: isSelected
@@ -1186,11 +1247,13 @@ function PaymentMethodSection({
period,
brandIds,
totalAmount,
onStaleBrandsFiltered,
}: {
planKey: string;
period: "monthly" | "yearly";
brandIds: string[];
totalAmount: number;
onStaleBrandsFiltered?: (validIds: string[]) => void;
}) {
const { t } = useTranslation();
const isFullPlan = planKey === "full";
@@ -1200,6 +1263,23 @@ function PaymentMethodSection({
staleTime: 5 * 60 * 1000,
enabled: !isFullPlan && brandIds.length > 0,
});
// Strip any locally-cached brand IDs that no longer exist server-side.
// Without this, Stripe checkout would 4xx on a stale ID and the user would
// see "payment failed" with no obvious cause.
useEffect(() => {
if (isFullPlan || !brands || brandIds.length === 0 || !onStaleBrandsFiltered) return;
const valid = new Set(brands.map((b) => b.id));
const filtered = brandIds.filter((id) => valid.has(id));
if (filtered.length !== brandIds.length) {
capture("stale_brand_ids_filtered", {
original: brandIds.length,
kept: filtered.length,
});
onStaleBrandsFiltered(filtered);
}
}, [brands, brandIds, isFullPlan, onStaleBrandsFiltered]);
const selectedBrandNames = (brands ?? [])
.filter((b) => brandIds.includes(b.id))
.map((b) => b.name);
@@ -1330,6 +1410,11 @@ function StickyCta({
}
}
const disabledHint = ctaDisabled
? t("subscription.stickyCta.disabledHint", {
count: Math.max(0, requiredBrands - selectedBrandIds.length),
})
: null;
return (
<div className="fixed inset-x-0 bottom-0 z-40 animate-fade-in-up border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 pb-[env(safe-area-inset-bottom,0px)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-3 px-6 py-3">
@@ -1344,7 +1429,14 @@ function StickyCta({
</span>
</span>
</div>
<Button size="lg" className="min-w-[200px]" onClick={onAdvance} disabled={ctaDisabled}>
<Button
size="lg"
className="min-w-[200px] disabled:cursor-not-allowed disabled:opacity-60"
onClick={onAdvance}
disabled={ctaDisabled}
title={disabledHint ?? undefined}
aria-disabled={ctaDisabled}
>
{ctaLabel}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
@@ -1719,7 +1811,7 @@ function ProvisioningView({
)}
{trialMutation.isError && (
<div className="flex flex-col items-center gap-3">
<p className="text-sm leading-5 text-red-600 dark:text-red-400">
<p className="text-sm leading-5 text-destructive">
{t("subscription.onboarding.error")}
</p>
<Button variant="outline" onClick={() => trialMutation.mutate()}>
@@ -1727,12 +1819,52 @@ function ProvisioningView({
</Button>
</div>
)}
<ProvisioningStuckHint trialMutation={trialMutation} />
</CardContent>
</Card>
</div>
);
}
// If trial provisioning hasn't resolved (success or error) within 30s, surface
// a hint with a retry + contact-support escape so the user isn't trapped
// staring at a spinner. Mirrors the modal-side guard in welcome-onboarding.
function ProvisioningStuckHint({ trialMutation }: { trialMutation: TrialMutation }) {
const { t } = useTranslation();
const [stuck, setStuck] = useState(false);
useEffect(() => {
if (trialMutation.isSuccess || trialMutation.isError) return;
const timer = setTimeout(() => setStuck(true), 30_000);
return () => clearTimeout(timer);
}, [trialMutation.isSuccess, trialMutation.isError]);
if (!stuck || trialMutation.isSuccess || trialMutation.isError) return null;
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-border bg-muted/40 p-4 text-center">
<p className="text-sm font-medium leading-5 text-foreground">
{t("subscription.onboarding.stillTryingTitle")}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t("subscription.onboarding.stillTryingHint")}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
capture("onboarding_support_clicked", { source: "subscription_page" });
import("@/lib/chatwoot").then((m) => m.openChatwoot());
}}
>
{t("subscription.onboarding.contactSupport")}
</Button>
<Button variant="ghost" size="sm" onClick={() => trialMutation.mutate()}>
{t("subscription.onboarding.retry")}
</Button>
</div>
</div>
);
}
function OnboardingCompletedView({
subscription,
navigate,
@@ -1756,7 +1888,7 @@ function OnboardingCompletedView({
{t("subscription.currentPlan")}
</span>
<Badge className="bg-brand text-brand-foreground">
{t("subscription.plans.full.name")}
{subscription?.plan?.name ?? t("subscription.plans.full.name")}
</Badge>
</div>
<div className="flex items-center justify-between">