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 @@ -1719,7 +1811,7 @@ function ProvisioningView({ )} {trialMutation.isError && (
-

+

{t("subscription.onboarding.error")}

)} +
); } +// 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 ( +
+

+ {t("subscription.onboarding.stillTryingTitle")} +

+

+ {t("subscription.onboarding.stillTryingHint")} +

+
+ + +
+
+ ); +} + function OnboardingCompletedView({ subscription, navigate, @@ -1756,7 +1888,7 @@ function OnboardingCompletedView({ {t("subscription.currentPlan")} - {t("subscription.plans.full.name")} + {subscription?.plan?.name ?? t("subscription.plans.full.name")}