dev #119

Merged
root merged 2 commits from dev into main 2026-06-09 18:22:36 +03:00
5 changed files with 321 additions and 171 deletions

View File

@@ -11,10 +11,23 @@ const dsn =
const customOtelEnabled = process.env.OTEL_ENABLED === "true";
// dev.sase.tr (staging) and sase.tr (prod) BOTH run NODE_ENV=production, so key
// off the canonical prod host (same signal as isCatalogBackfillEnabled) to tag
// dev events as "staging". An explicit SENTRY_ENVIRONMENT always wins.
const isProdHost =
process.env.COOLIFY_FQDN === "sase.tr" || process.env.BETTER_AUTH_URL === "https://sase.tr";
const environment =
process.env.SENTRY_ENVIRONMENT ||
(isProdHost
? "production"
: process.env.NODE_ENV === "production"
? "staging"
: process.env.NODE_ENV || "development");
if (dsn && process.env.NODE_ENV !== "test") {
Sentry.init({
dsn,
environment: process.env.NODE_ENV || "development",
environment,
serverName: "sase-worker",
sendDefaultPii: true,
enableLogs: true,

View File

@@ -15,10 +15,24 @@ const dsn =
// so they're effectively disabled when the custom pipeline is on.
const customOtelEnabled = process.env.OTEL_ENABLED === "true";
// dev.sase.tr (staging) and sase.tr (prod) BOTH run NODE_ENV=production, so
// NODE_ENV alone can't separate them in Sentry. Key off the canonical prod host
// (same signal as isCatalogBackfillEnabled) so dev events tag as "staging" and
// stay filterable from prod. An explicit SENTRY_ENVIRONMENT always wins.
const isProdHost =
process.env.COOLIFY_FQDN === "sase.tr" || process.env.BETTER_AUTH_URL === "https://sase.tr";
const environment =
process.env.SENTRY_ENVIRONMENT ||
(isProdHost
? "production"
: process.env.NODE_ENV === "production"
? "staging"
: process.env.NODE_ENV || "development");
if (dsn && process.env.NODE_ENV !== "test") {
Sentry.init({
dsn,
environment: process.env.NODE_ENV || "development",
environment,
sendDefaultPii: true,
enableLogs: true,
skipOpenTelemetrySetup: customOtelEnabled,

View File

@@ -0,0 +1,78 @@
import { Button } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
/**
* Soft B2B qualifier shown before the register form. SASE.TR is a business tool
* (parts dealers / wholesalers / services) — paid ads pull a lot of end-users who
* trial but never pay (~1% trial→paid). This self-selection step filters the
* worst-fit segment (vehicle owners) without a hard gate, and captures the chosen
* segment so qualified-trial ratio is measurable and feedable to Meta CAPI later.
*
* Copy follows the B2B framing rule: never "kendi aracınız"; frame around
* "sınırsız şase sorgulama / parça satıcısı & servis".
*/
const SEGMENTS = [
{ key: "parts_dealer", label: "Yedek Parça Satıcısı", desc: "Perakende parça mağazası" },
{ key: "wholesaler", label: "Toptancı / Distribütör", desc: "Toptan parça tedariki" },
{ key: "ecommerce", label: "E-ticaret Satıcısı", desc: "Online parça satışı" },
{ key: "service_fleet", label: "Servis / Tamirhane / Filo", desc: "Oto servis veya filo bakımı" },
] as const;
export function SegmentQualifier({ onSelect }: { onSelect: (segment: string) => void }) {
const [ownerNote, setOwnerNote] = useState(false);
if (ownerNote) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
<h2 className="text-lg font-bold">SASE.TR işletmeler için bir araçtır</h2>
<p className="mt-2 text-sm text-muted-foreground">
SASE.TR; yedek parça satıcıları, toptancılar ve servisler için{" "}
<b className="text-foreground">sınırsız şase sorgulama</b> platformudur bireysel araç
sahiplerine yönelik değildir. Tek bir araç için parça arıyorsan bir parça satıcısına
danışman daha hızlı olur.
</p>
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" className="w-full" onClick={() => onSelect("vehicle_owner")}>
Yine de devam et
</Button>
<Link to="/" className="w-full">
<Button variant="ghost" className="w-full">
Ana sayfaya dön
</Button>
</Link>
</div>
</div>
);
}
return (
<div className="grid gap-2.5">
{SEGMENTS.map((s) => (
<button
key={s.key}
type="button"
onClick={() => onSelect(s.key)}
className="group flex items-center justify-between rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-brand hover:bg-brand/5"
>
<span className="min-w-0">
<span className="block font-semibold">{s.label}</span>
<span className="block text-xs text-muted-foreground">{s.desc}</span>
</span>
<span className="shrink-0 pl-3 text-muted-foreground transition-colors group-hover:text-brand">
</span>
</button>
))}
<button
type="button"
onClick={() => setOwnerNote(true)}
className="rounded-xl border border-dashed border-border p-3 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Araç Sahibiyim
</button>
</div>
);
}

View File

@@ -21,8 +21,20 @@ export async function initSentry() {
return;
}
// Both dev.sase.tr (staging) and sase.tr (prod) ship a production Vite build
// (MODE === "production"), so MODE can't separate them. Derive from the host at
// runtime instead, so dev errors stay filterable from prod in Sentry. An
// explicit VITE_SENTRY_ENVIRONMENT still wins.
const host = typeof window !== "undefined" ? window.location.hostname : "";
const environment =
import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE ?? "production";
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ??
(host === "sase.tr" || host === "www.sase.tr"
? "production"
: host.endsWith("dev.sase.tr")
? "staging"
: import.meta.env.DEV
? "development"
: "staging");
const release = import.meta.env.VITE_SENTRY_RELEASE;
try {

View File

@@ -1,9 +1,10 @@
import { SegmentQualifier } from "@/components/auth/segment-qualifier";
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { api } from "@/lib/api-client";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { track as trackMeta } from "@/lib/meta-pixel";
import { capture, identifyUser } from "@/lib/posthog";
import { capture, identifyUser, setPeopleProperties } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { cleanModelName } from "@/lib/vehicle";
import { suggestEmailFix } from "@sase/shared";
@@ -64,6 +65,17 @@ function RegisterPage() {
staleTime: 5 * 60_000,
});
// Soft B2B qualifier (Phase 1): gate the form behind a segment choice so
// end-users self-select out and the chosen segment is captured for qualified-
// trial measurement. Persisted so a reload / OAuth round-trip doesn't re-ask.
const [segment, setSegment] = useState<string | null>(() => {
try {
return localStorage.getItem("sase-b2b-segment");
} catch {
return null;
}
});
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [emailSuggestion, setEmailSuggestion] = useState<string | null>(null);
@@ -89,6 +101,19 @@ function RegisterPage() {
example === "1" ? "&example=1" : "",
].join("");
function handleSegment(seg: string) {
try {
localStorage.setItem("sase-b2b-segment", seg);
} catch {
// localStorage unavailable — proceed without persistence.
}
setSegment(seg);
capture("signup_segment_selected", { segment: seg });
// Person property so qualified-trial ratio and channel→segment→paid are
// queryable, and the segment can seed a Meta audience / CAPI signal later.
setPeopleProperties({ b2b_segment: seg });
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
startAction("register", { method: "email" });
@@ -149,7 +174,7 @@ function RegisterPage() {
// The referral code travels via `?ref=` in redirectUrl; the welcome
// onboarding modal on the search page is the single place that applies it
// (covers both email and Google OAuth signups).
capture("user_signed_up", { method: "email" });
capture("user_signed_up", { method: "email", segment });
// CompleteRegistration: browser pixel + server-side CAPI (adds fbp/fbc/IP/UA
// for ad-click attribution), deduped via the shared event id `signup_<userId>`.
const metaEventId = data?.user ? `signup_${data.user.id}` : undefined;
@@ -223,10 +248,12 @@ function RegisterPage() {
{/* Heading */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
{vin ? "Hesap Aç ve Katalogu Gör" : "Hesap Aç"}
{!segment ? "Bu platform kimler için?" : vin ? "Hesap Aç ve Katalogu Gör" : "Hesap Aç"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Sınırsız şase sorgulamak için ücretsiz hesap
{!segment
? "İşletmeni seç — sana uygun şase sorgulama erişimini açalım."
: "Sınırsız şase sorgulamak için ücretsiz hesap aç"}
</p>
{/* Trust strip — kartsız + iptal + KVKK/SSL rozetleri tek satır */}
@@ -236,177 +263,183 @@ function RegisterPage() {
</div>
</div>
{/* Google */}
<Button
variant="outline"
className="w-full"
onClick={() => {
startAction("register", { method: "google" });
capture("user_signed_up", { method: "google" });
// Meta CompleteRegistration for Google is sent SERVER-SIDE on actual
// account creation (auth databaseHook). The browser pixel here fired on
// click (before the user even finished Google auth) and would double-
// count, so it's intentionally not sent client-side for OAuth.
if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan);
signIn.social({ provider: "google", callbackURL: redirectUrl });
}}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google ile Kayıt Ol
</Button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="name">Ad Soyad</Label>
<Input
id="name"
type="text"
placeholder="Ad Soyad"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-posta</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
// Hide a previously shown suggestion as soon as the user keeps
// typing — recompute on blur so we don't nag mid-typing.
if (emailSuggestion) setEmailSuggestion(null);
{!segment ? (
<SegmentQualifier onSelect={handleSegment} />
) : (
<>
{/* Google */}
<Button
variant="outline"
className="w-full"
onClick={() => {
startAction("register", { method: "google" });
capture("user_signed_up", { method: "google", segment });
// Meta CompleteRegistration for Google is sent SERVER-SIDE on actual
// account creation (auth databaseHook). The browser pixel here fired on
// click (before the user even finished Google auth) and would double-
// count, so it's intentionally not sent client-side for OAuth.
if (plan) localStorage.setItem(PENDING_PLAN_KEY, plan);
signIn.social({ provider: "google", callbackURL: redirectUrl });
}}
onBlur={() => setEmailSuggestion(suggestEmailFix(email))}
required
/>
{emailSuggestion && (
<p className="text-sm text-muted-foreground">
Bunu mu demek istedin?{" "}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google ile Kayıt Ol
</Button>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="name">Ad Soyad</Label>
<Input
id="name"
type="text"
placeholder="Ad Soyad"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-posta</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
// Hide a previously shown suggestion as soon as the user keeps
// typing — recompute on blur so we don't nag mid-typing.
if (emailSuggestion) setEmailSuggestion(null);
}}
onBlur={() => setEmailSuggestion(suggestEmailFix(email))}
required
/>
{emailSuggestion && (
<p className="text-sm text-muted-foreground">
Bunu mu demek istedin?{" "}
<button
type="button"
className="text-primary underline underline-offset-2 hover:opacity-80"
onClick={() => {
setEmail(emailSuggestion);
setEmailSuggestion(null);
// PostHog signal so we can see how often the suggestion is
// accepted vs ignored — informs whether to keep the
// dictionary growing or just trust browser-native typo hints.
capture("signup_email_typo_corrected", {
from: email,
to: emailSuggestion,
});
}}
>
{emailSuggestion}
</button>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Şifre</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="En az 8 karakter"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-foreground"
aria-label={showPassword ? "Şifreyi gizle" : "Şifreyi göster"}
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
{password.length > 0 && (
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
{passwordRules.map((rule) => (
<li
key={rule.label}
className={`flex items-center gap-1 ${
rule.ok ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
}`}
>
<Check className={`size-3 ${rule.ok ? "opacity-100" : "opacity-30"}`} />
{rule.label}
</li>
))}
</ul>
)}
</div>
{showRefCode ? (
<div className="space-y-2">
<Label htmlFor="refCode">
Referans Kodu <span className="text-muted-foreground">(opsiyonel)</span>
</Label>
<Input
id="refCode"
type="text"
placeholder="örn. MEU8EHF7"
value={refCode}
onChange={(e) => setRefCode(e.target.value.toUpperCase())}
maxLength={20}
autoComplete="off"
className="font-mono uppercase"
/>
</div>
) : (
<button
type="button"
className="text-primary underline underline-offset-2 hover:opacity-80"
onClick={() => {
setEmail(emailSuggestion);
setEmailSuggestion(null);
// PostHog signal so we can see how often the suggestion is
// accepted vs ignored — informs whether to keep the
// dictionary growing or just trust browser-native typo hints.
capture("signup_email_typo_corrected", {
from: email,
to: emailSuggestion,
});
}}
onClick={() => setShowRefCode(true)}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{emailSuggestion}
Referans kodun var mı?
</button>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Şifre</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="En az 8 karakter"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="pr-10"
)}
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-foreground"
aria-label={showPassword ? "Şifreyi gizle" : "Şifreyi göster"}
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
{password.length > 0 && (
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
{passwordRules.map((rule) => (
<li
key={rule.label}
className={`flex items-center gap-1 ${
rule.ok ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
}`}
>
<Check className={`size-3 ${rule.ok ? "opacity-100" : "opacity-30"}`} />
{rule.label}
</li>
))}
</ul>
)}
</div>
{showRefCode ? (
<div className="space-y-2">
<Label htmlFor="refCode">
Referans Kodu <span className="text-muted-foreground">(opsiyonel)</span>
</Label>
<Input
id="refCode"
type="text"
placeholder="örn. MEU8EHF7"
value={refCode}
onChange={(e) => setRefCode(e.target.value.toUpperCase())}
maxLength={20}
autoComplete="off"
className="font-mono uppercase"
/>
</div>
) : (
<button
type="button"
onClick={() => setShowRefCode(true)}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
Referans kodun var mı?
</button>
)}
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Kayıt yapılıyor..." : vin ? "Hesap Aç ve Katalogu Gör" : "Hesap Aç"}
</Button>
</form>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Kayıt yapılıyor..." : vin ? "Hesap Aç ve Katalogu Gör" : "Hesap Aç"}
</Button>
</form>
</>
)}
<p className="text-center text-xs text-muted-foreground">
Kayıt olarak{" "}