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
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:
145
apps/web/src/components/auth/segment-gate.tsx
Normal file
145
apps/web/src/components/auth/segment-gate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user