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>
146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
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>
|
||
);
|
||
}
|