diff --git a/apps/web/src/components/auth/segment-gate.tsx b/apps/web/src/components/auth/segment-gate.tsx new file mode 100644 index 0000000..431596f --- /dev/null +++ b/apps/web/src/components/auth/segment-gate.tsx @@ -0,0 +1,145 @@ +import { SegmentQualifier } from "@/components/auth/segment-qualifier"; +import { useAuth } from "@/hooks/use-auth"; +import { capture, setPeopleProperties } from "@/lib/posthog"; +import { Button } from "@sase/ui"; +import { useLocation } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +/** + * Universal post-auth segment gate. + * + * The register page gates segment selection behind the form, but that only + * covers email + register-page-OAuth signups. A user who starts Google OAuth + * from the LOGIN page (login.tsx) gets an account created server-side and lands + * on the dashboard with no segment ever chosen — this is the bulk of the ~41% + * of signups with a null segment. Segment isn't persisted server-side (only + * localStorage + a PostHog person property), so we enforce it here at the one + * chokepoint every authenticated user passes through: the dashboard. + * + * Behaviour (product decision 2026-07-27): + * - New users (account created in the last few minutes → the login-OAuth gap): + * MANDATORY, non-dismissible modal. + * - Existing segment-less users (the ~538 backfill): SOFT, dismissible with a + * cooldown so we re-ask later without nagging or blocking active/paying users. + * + * This is a baseline fix shipped to everyone — NOT the `funnel-bucket` A/B + * experiment (that gates the tailored value/upgrade experience separately). + */ +const SEGMENT_KEY = "sase-b2b-segment"; +const DISMISS_UNTIL_KEY = "sase-b2b-segment-prompt-until"; +// Account younger than this ⇒ treat as a fresh signup (mandatory). Generous so +// the signup → first dashboard load always counts, even with a slow OAuth hop. +const NEW_USER_WINDOW_MS = 15 * 60 * 1000; +// How long a "Daha sonra" dismissal silences the soft prompt for existing users. +const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; + +function readSegment(): string | null { + try { + return localStorage.getItem(SEGMENT_KEY); + } catch { + return null; + } +} + +export function SegmentGate() { + const { user, isLoading } = useAuth(); + const location = useLocation(); + const [open, setOpen] = useState(false); + const [mandatory, setMandatory] = useState(false); + + useEffect(() => { + // Only inside the app, only once the session has resolved to a real user. + if (isLoading || !user) { + setOpen(false); + return; + } + if (!location.pathname.startsWith("/dashboard")) { + setOpen(false); + return; + } + // Already segmented — nothing to do. (Register writes this pre-redirect, so + // email + register-OAuth signups never see the gate.) + if (readSegment()) { + setOpen(false); + return; + } + + const createdAt = user.createdAt ? new Date(user.createdAt).getTime() : 0; + const isNew = createdAt > 0 && Date.now() - createdAt < NEW_USER_WINDOW_MS; + + if (isNew) { + setMandatory(true); + setOpen(true); + return; + } + + // Existing segment-less user: honour the soft-dismiss cooldown. + let dismissedUntil = 0; + try { + dismissedUntil = Number(localStorage.getItem(DISMISS_UNTIL_KEY)) || 0; + } catch { + dismissedUntil = 0; + } + if (Date.now() < dismissedUntil) { + setOpen(false); + return; + } + setMandatory(false); + setOpen(true); + }, [user, isLoading, location.pathname]); + + if (!open) return null; + + function handleSelect(seg: string) { + try { + localStorage.setItem(SEGMENT_KEY, seg); + } catch { + // localStorage unavailable — still fire analytics below. + } + // `source` distinguishes the login-OAuth gap fill from the backfill so we can + // measure each in PostHog; mirrors register.tsx's signup_segment_selected. + capture("signup_segment_selected", { + segment: seg, + source: mandatory ? "post_signup_gate" : "backfill_gate", + }); + setPeopleProperties({ b2b_segment: seg }); + setOpen(false); + } + + function handleDismiss() { + try { + localStorage.setItem(DISMISS_UNTIL_KEY, String(Date.now() + DISMISS_COOLDOWN_MS)); + } catch { + // best-effort + } + capture("signup_segment_prompt_dismissed"); + setOpen(false); + } + + return ( +
would need imperative showModal() plumbing this SPA overlay doesn't use + role="dialog" + aria-modal="true" + aria-label="İşletme türü seçimi" + > +
+
+

Bu platform kimler için?

+

+ İşletmeni seç — sana uygun sınırsız şase sorgulama erişimini açalım. +

+
+
+ +
+ {!mandatory && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/trial-value-upsell.tsx b/apps/web/src/components/trial-value-upsell.tsx index 9e1691b..05e4493 100644 --- a/apps/web/src/components/trial-value-upsell.tsx +++ b/apps/web/src/components/trial-value-upsell.tsx @@ -1,7 +1,7 @@ import { api } from "@/lib/api-client"; import { useSession } from "@/lib/auth-client"; import { useTranslation } from "@/lib/i18n"; -import { capture } from "@/lib/posthog"; +import { capture, subscribeFeatureFlag } from "@/lib/posthog"; import { Button } from "@sase/ui"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; @@ -25,6 +25,31 @@ const VALUE_THRESHOLD = 3; // Urgency banner owns days <= 3; this one owns days > 3 (no overlap). const URGENCY_WINDOW_DAYS = 3; +// Kova B of the `funnel-bucket` A/B experiment: segment-tailored, Meta-proven +// value copy for the b2b_qualified variant. These hooks are the ones that won on +// Meta (iade / yanlış-parça / sınırsız-şase framing). Only real B2B segments get +// the pitch; vehicle_owner / unknown fall back to the neutral control copy +// (product decision 2026-07-27 — don't push a subscription on consumers). +const SEGMENT_KEY = "sase-b2b-segment"; +const B2B_COPY: Record = { + parts_dealer: { + title: "Yanlış parça siparişine son", + desc: "Dükkânına gelen her araçta şaseden doğru OEM parça — iade ve stok derdi yok.", + }, + wholesaler: { + title: "Her siparişte birebir OEM", + desc: "Toptan siparişlerde şaseden doğru OEM eşleşmesi — yanlış kalem, iade yok.", + }, + ecommerce: { + title: "Listelemende doğru parça", + desc: "Şaseden doğru OEM ile hatalı satışı ve iade oranını düşür.", + }, + service_fleet: { + title: "Serviste doğru parça, ilk seferde", + desc: "Her araçta şaseden doğru OEM — bekleme yok, yanlış sipariş yok.", + }, +}; + function dismissStorageKey(userId: string | null | undefined, endDate: string): string { return `trialValueUpsellDismissed-${userId ?? "anon"}-${endDate}`; } @@ -80,6 +105,26 @@ export function TrialValueUpsell() { const visible = inWindow && hasProvenValue && !dismissed; + // Read the experiment flag ONLY once the nudge is visible, so the PostHog + // exposure ($feature_flag_called) population = activated trial users who + // actually see it — not every dashboard mount. Keeps the thin signal undiluted. + const [bucket, setBucket] = useState(undefined); + useEffect(() => { + if (!visible) return; + return subscribeFeatureFlag("funnel-bucket", setBucket); + }, [visible]); + const [segment] = useState(() => { + try { + return localStorage.getItem(SEGMENT_KEY); + } catch { + return null; + } + }); + const tailored = + bucket === "b2b_qualified" && segment && segment !== "vehicle_owner" + ? B2B_COPY[segment] + : undefined; + useEffect(() => { if (!visible || viewedRef.current || days === null) return; viewedRef.current = true; @@ -120,16 +165,16 @@ export function TrialValueUpsell() {

- {t("subscription.valueUpsell.title")} + {tailored ? tailored.title : t("subscription.valueUpsell.title")}

- {t("subscription.valueUpsell.description")} + {tailored ? tailored.desc : t("subscription.valueUpsell.description")}

diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e4858b8..24e8e7e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { SegmentGate } from "@/components/auth/segment-gate"; import { InAppEscape } from "@/components/in-app-escape"; import { SurveyPopover } from "@/components/survey-popover"; import { useAuth } from "@/hooks/use-auth"; @@ -157,6 +158,7 @@ function RootComponent() { +