Files
sase.tr/apps/web/src/components/auth/segment-gate.tsx
Semih Yesilyurt 55b4cd38e9
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
feat(growth): B2B segment kapısı + funnel-bucket A/B deneyi (Kova B)
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>
2026-07-27 17:26:19 +03:00

146 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}