Merge pull request 'dev' (#119) from dev into main
Reviewed-on: #119
This commit was merged in pull request #119.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
78
apps/web/src/components/auth/segment-qualifier.tsx
Normal file
78
apps/web/src/components/auth/segment-qualifier.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 aç
|
||||
{!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{" "}
|
||||
|
||||
Reference in New Issue
Block a user