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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user