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>
);
}