From 611b41c793cbc6d13039e1360af1649b962109d8 Mon Sep 17 00:00:00 2001
From: Semih Yesilyurt
Date: Sun, 31 May 2026 23:04:20 +0300
Subject: [PATCH] =?UTF-8?q?feat(web):=20subscription=20=E2=80=94=20radiogr?=
=?UTF-8?q?oup=20plan=20picker,=20trial=20stuck=20guard,=20stale-brand=20c?=
=?UTF-8?q?leanup,=20scoped=20LS?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
apps/web/src/messages/en.json | 7 +-
apps/web/src/messages/tr.json | 7 +-
.../routes/dashboard/subscription/index.tsx | 152 ++++++++++++++++--
3 files changed, 152 insertions(+), 14 deletions(-)
diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json
index 82321f5..9b01208 100644
--- a/apps/web/src/messages/en.json
+++ b/apps/web/src/messages/en.json
@@ -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",
diff --git a/apps/web/src/messages/tr.json b/apps/web/src/messages/tr.json
index 17d3bac..439d273 100644
--- a/apps/web/src/messages/tr.json
+++ b/apps/web/src/messages/tr.json
@@ -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",
diff --git a/apps/web/src/routes/dashboard/subscription/index.tsx b/apps/web/src/routes/dashboard/subscription/index.tsx
index 23cf989..c026643 100644
--- a/apps/web/src/routes/dashboard/subscription/index.tsx
+++ b/apps/web/src/routes/dashboard/subscription/index.tsx
@@ -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 (
+
+
+ {slow && (
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+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,
})}
- }>
+ }>
)}
@@ -1040,7 +1094,11 @@ export function PlanGrid({
}) {
const { t } = useTranslation();
return (
-
+
{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
would force us to rebuild the visuals while losing focus/keyboard behavior we already get from