feat(growth): B2B segment kapısı + funnel-bucket A/B deneyi (Kova B)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Fix (herkes, flag'siz): evrensel post-auth segment kapısı (segment-gate.tsx).
Kayıtların ~%41'i segmentsizdi çünkü login.tsx Google OAuth segment adımını
atlıyordu. Yeni kullanıcıya zorunlu (açığı kapatır), mevcut segmentsize
7g-cooldown'lu yumuşak prompt (~538 backfill). Segment localStorage +
PostHog person prop (b2b_segment); backend persist faz 2.

Kova B (funnel-bucket flag, b2b_qualified): trial-value-upsell'e segmente-özel
Meta-kanıtlı kopya (iade / yanlış-parça / sınırsız-şase). Flag SADECE banner
görünürken okunur → deney maruziyeti = gerçekten gören aktif trial'lar.
vehicle_owner / bilinmeyen segment → nötr control kopya (ürün kararı).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:25:02 +03:00
parent 6610bdf720
commit 55b4cd38e9
3 changed files with 196 additions and 4 deletions

View File

@@ -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 (
<div
className="fixed inset-0 z-[60] flex items-center justify-center overflow-y-auto bg-background/80 p-4 backdrop-blur-sm"
// biome-ignore lint/a11y/useSemanticElements: overlay modal — role="dialog"+aria-modal is the correct ARIA here; a native <dialog> would need imperative showModal() plumbing this SPA overlay doesn't use
role="dialog"
aria-modal="true"
aria-label="İşletme türü seçimi"
>
<div className="my-auto w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-xl">
<div>
<h2 className="text-xl font-bold tracking-tight">Bu platform kimler için?</h2>
<p className="mt-2 text-sm text-muted-foreground">
İşletmeni seç sana uygun sınırsız şase sorgulama erişimini açalım.
</p>
</div>
<div className="mt-5">
<SegmentQualifier onSelect={handleSelect} />
</div>
{!mandatory && (
<Button variant="ghost" className="mt-3 w-full" onClick={handleDismiss}>
Daha sonra
</Button>
)}
</div>
</div>
);
}

View File

@@ -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<string, { title: string; desc: string }> = {
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<string | boolean | undefined>(undefined);
useEffect(() => {
if (!visible) return;
return subscribeFeatureFlag("funnel-bucket", setBucket);
}, [visible]);
const [segment] = useState<string | null>(() => {
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() {
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div>
<p className="text-sm font-semibold text-foreground">
{t("subscription.valueUpsell.title")}
{tailored ? tailored.title : t("subscription.valueUpsell.title")}
</p>
<p className="text-xs text-muted-foreground">
{t("subscription.valueUpsell.description")}
{tailored ? tailored.desc : t("subscription.valueUpsell.description")}
</p>
</div>
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
<Button size="sm" className="bg-brand text-white hover:bg-brand/90">
{t("subscription.valueUpsell.cta")}
{tailored ? "Sınırsız erişime geç" : t("subscription.valueUpsell.cta")}
</Button>
</Link>
</div>

View File

@@ -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() {
</a>
<InAppEscape />
<Outlet />
<SegmentGate />
<SurveyPopover />
<Toaster position="top-center" />
</>