refactor: kullanici feature'ı — giriş kartları feature bileşenlerine çıktı (Faz 4)

- giris-form, giris-baslik, user-nav, sign-out-button
  features/kullanici/components/ altına taşındı
- /giris'in session+redirect+callback temizliği GirisKarti'ya (async),
  /giris/dogrula'nın inline doğrulama formu + guvenliYol kalkanı
  DogrulaKarti'ya çıkarıldı; skeleton'lar aynı dosyada
- İki sayfa senkron: searchParams.then() + sayfada Suspense;
  app/giris/loading.tsx silindi (geometrisi GirisKartiSkeleton'da yaşıyor)
- site-header'ın UserNav importu güncellendi; mevcut Suspense sınırı yerinde
- Davranış değişikliği yok

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 01:22:31 +03:00
parent e3cf141f55
commit 8b5aa8c8a6
10 changed files with 254 additions and 195 deletions

View File

@@ -1,30 +1,16 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { PixelCorner } from "@/components/pixel-decor";
import { Button } from "@/components/ui/button";
import {
DogrulaKarti,
DogrulaKartiSkeleton,
} from "@/features/kullanici/components/dogrula-karti";
export const metadata: Metadata = {
title: "Girişini onayla",
robots: { index: false, follow: false },
};
/** Yalnızca site içi (göreli) callback'lere izin ver — open redirect kalkanı. */
function guvenliYol(deger: string | undefined, varsayilan: string): string {
return deger && deger.startsWith("/") && !deger.startsWith("//")
? deger
: varsayilan;
}
/**
* Maildeki giriş bağlantısının indiği onay sayfası. Doğrulama ucu
* (/api/auth/magic-link/verify) GET'lendiği anda token'ı tüketir; mail
* güvenlik tarayıcıları ve tarayıcı ön-yüklemeleri linki kullanıcıdan önce
* açıp token'ı yakabildiği için maildeki link token'ı TÜKETMEYEN bu sayfaya
* gelir. Verify ancak buradaki form gönderilince tetiklenir — tarayıcı botları
* sayfayı GET'lese bile form'u göndermez, token yanmaz.
*/
export default async function DogrulaPage({
export default function DogrulaPage({
searchParams,
}: {
searchParams: Promise<{
@@ -34,68 +20,19 @@ export default async function DogrulaPage({
newUserCallbackURL?: string;
}>;
}) {
const { token, callbackURL, errorCallbackURL, newUserCallbackURL } =
await searchParams;
if (!token) redirect("/giris");
const callback = guvenliYol(callbackURL, "/");
const hataCallback = guvenliYol(
errorCallbackURL,
`/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callback)}`,
);
const yeniKullaniciCallback = guvenliYol(newUserCallbackURL, callback);
return (
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-xl font-bold">Girişini onayla</h1>
<p className="mt-2 text-sm text-slate-600">
Bu bağlantı senin için üretildi ve{" "}
<span className="font-semibold">tek kullanımlık</span>. Aşağıdaki
butona tıkladığında KolayTercih hesabına giriş yapacaksın.
</p>
<form
method="GET"
action="/api/auth/magic-link/verify"
className="mt-6"
>
<input type="hidden" name="token" value={token} />
<input type="hidden" name="callbackURL" value={callback} />
<input
type="hidden"
name="errorCallbackURL"
value={hataCallback}
/>
<input
type="hidden"
name="newUserCallbackURL"
value={yeniKullaniciCallback}
/>
<Button
type="submit"
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Giriş yap
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>
<p className="mt-4 text-xs leading-relaxed text-slate-500">
Bu isteği sen yapmadıysan bu sayfayı kapatabilirsin butona
tıklanmadığı sürece hesabında hiçbir şey değişmez.
</p>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
<Suspense fallback={<DogrulaKartiSkeleton />}>
{searchParams.then((sp) => (
<DogrulaKarti
token={sp.token}
callbackURL={sp.callbackURL}
errorCallbackURL={sp.errorCallbackURL}
newUserCallbackURL={sp.newUserCallbackURL}
/>
))}
</Suspense>
</main>
</div>
);

View File

@@ -1,40 +0,0 @@
"use client";
import { TypingAnimation } from "@/components/ui/typing-animation";
// Hepsi aynı şablon: "Giriş yap, çünkü …"
const FUNNEL_MESAJLARI = [
", çünkü listen seni bekliyor",
", çünkü tercihine 1 adım kaldı",
", çünkü seçimlerin hazır",
", çünkü 5 kredin yanında",
", çünkü kaçırmak istemezsin",
", çünkü listen hemen üretilsin",
];
/**
* Giriş sayfası başlığı. Kullanıcı sihirbazdan (callback /sonuc'a dönüyorsa)
* geldiyse sabit "Giriş yap" + typewriter hook'ları; diğer girişlerde
* yalnız sabit başlık.
*/
export function GirisBaslik({ funnel }: { funnel: boolean }) {
if (!funnel) {
return <h1 className="font-heading text-xl font-bold">Giriş yap</h1>;
}
return (
<h1 className="flex flex-wrap items-baseline font-heading text-xl font-bold leading-tight">
<span className="shrink-0">Giriş yap</span>
<TypingAnimation
as="span"
words={FUNNEL_MESAJLARI}
loop
startOnView={false}
typeSpeed={42}
deleteSpeed={22}
pauseDelay={2400}
className="min-h-[1.25em] font-heading text-xl font-bold leading-tight tracking-[-0.02em] text-primary"
/>
</h1>
);
}

View File

@@ -1,168 +0,0 @@
"use client";
import { useState } from "react";
import { Mail } from "lucide-react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { olay } from "@/lib/analitik";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
function GoogleIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" aria-hidden>
<path
fill="#4285F4"
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.27-4.74 3.27-8.1Z"
/>
<path
fill="#34A853"
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.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84Z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15A11 11 0 0 0 2.18 7.06L5.84 9.9C6.71 7.31 9.14 5.38 12 5.38Z"
/>
</svg>
);
}
export function GirisForm({ callbackURL }: { callbackURL: string }) {
const [email, setEmail] = useState("");
const [busy, setBusy] = useState<"google" | "email" | null>(null);
const [sent, setSent] = useState(false);
// Yeniden gönderim arası bekleme (sn) — istemeden spam'lenmesin
const [bekleme, setBekleme] = useState(0);
function geriSayimBaslat() {
setBekleme(30);
const sayac = setInterval(() => {
setBekleme((s) => {
if (s <= 1) {
clearInterval(sayac);
return 0;
}
return s - 1;
});
}, 1000);
}
async function googleIleGiris() {
setBusy("google");
olay("giris_denendi", { yontem: "google" });
const { error } = await authClient.signIn.social({
provider: "google",
callbackURL,
});
if (error) {
toast.error("Google ile giriş başarısız. Tekrar dener misin?");
setBusy(null);
}
}
async function linkGonder(e?: React.FormEvent) {
e?.preventDefault();
if (!/^\S+@\S+\.\S+$/.test(email)) {
toast.error("Geçerli bir e-posta adresi gir.");
return;
}
setBusy("email");
const { error } = await authClient.signIn.magicLink({
email,
callbackURL,
// Geçersiz/süresi dolmuş token'da kullanıcı ham ?error=INVALID_TOKEN
// parametresiyle baş başa kalmasın: giriş sayfası bu değeri dostça bir
// mesaja çevirir, callback korunur (bkz. src/app/giris/page.tsx).
errorCallbackURL: `/giris?hata=gecersiz-baglanti&callback=${encodeURIComponent(callbackURL)}`,
});
setBusy(null);
if (error) {
toast.error("Bağlantı gönderilemedi. Tekrar dener misin?");
return;
}
setSent(true);
geriSayimBaslat();
olay("giris_denendi", { yontem: "eposta" });
}
if (sent) {
return (
<div className="mt-6 space-y-3">
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
<p className="font-semibold">Bağlantı gönderildi 📬</p>
<p className="mt-1">
<span className="font-medium">{email}</span> adresine bir giriş
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et
bağlantı 15 dakika geçerli.
</p>
</div>
<div className="flex items-center justify-center gap-4 text-sm">
<button
type="button"
onClick={() => linkGonder()}
disabled={busy !== null || bekleme > 0}
className="cursor-pointer font-medium text-primary underline-offset-2 hover:underline disabled:cursor-default disabled:text-slate-400 disabled:no-underline"
>
{busy === "email"
? "Gönderiliyor…"
: bekleme > 0
? `Yeniden gönder (${bekleme})`
: "Yeniden gönder"}
</button>
<button
type="button"
onClick={() => setSent(false)}
className="cursor-pointer text-slate-500 underline-offset-2 hover:underline"
>
E-postayı düzelt
</button>
</div>
</div>
);
}
return (
<div className="mt-6 space-y-4">
<Button
variant="outline"
className="h-11 w-full cursor-pointer"
onClick={googleIleGiris}
disabled={busy !== null}
>
<GoogleIcon />
{busy === "google" ? "Yönlendiriliyorsun…" : "Google ile devam et"}
</Button>
<div className="flex items-center gap-3 text-xs text-slate-400">
<div className="h-px flex-1 bg-slate-200" />
veya
<div className="h-px flex-1 bg-slate-200" />
</div>
<form onSubmit={linkGonder} className="space-y-3">
<Input
type="email"
inputMode="email"
autoComplete="email"
placeholder="E-posta adresin"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy !== null}
className="h-11"
/>
<Button
type="submit"
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
disabled={busy !== null}
>
<Mail className="size-4" aria-hidden />
{busy === "email" ? "Gönderiliyor…" : "Giriş bağlantısı gönder"}
</Button>
</form>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık,
// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir —
// içerik gelince zıplama olmaz.
export default function GirisLoading() {
return (
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{/* Başlık + alt metin */}
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
{/* Google ile devam et */}
<Skeleton className="mt-6 h-11 w-full rounded-lg" />
{/* "veya" ayracı */}
<div className="mt-4 flex items-center gap-3">
<Skeleton className="h-px flex-1" />
<Skeleton className="h-3 w-8" />
<Skeleton className="h-px flex-1" />
</div>
{/* E-posta alanı + gönder butonu */}
<Skeleton className="mt-4 h-11 w-full rounded-lg" />
<Skeleton className="mt-3 h-11 w-full rounded-lg" />
</div>
{/* Kart altındaki yasal metin */}
<div className="mt-6 flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</main>
</div>
);
}

View File

@@ -1,95 +1,30 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { PixelCorner } from "@/components/pixel-decor";
import { GirisForm } from "./giris-form";
import { GirisBaslik } from "./giris-baslik";
import {
GirisKarti,
GirisKartiSkeleton,
} from "@/features/kullanici/components/giris-karti";
export const metadata: Metadata = {
title: "Giriş",
robots: { index: false, follow: true },
};
export default async function GirisPage({
export default function GirisPage({
searchParams,
}: {
searchParams: Promise<{ callback?: string; hata?: string }>;
}) {
const { callback, hata } = await searchParams;
const callbackURL =
callback && callback.startsWith("/") ? callback : "/";
// Magic link doğrulaması başarısız olunca errorCallbackURL buraya düşürür
// (bkz. giris-form.tsx ve /giris/dogrula) — ham INVALID_TOKEN yerine
// dostça bir mesaj gösterip yeni bağlantı istemeye yönlendiriyoruz.
const baglantiHatasi = hata === "gecersiz-baglanti";
const session = await getSession();
if (session) redirect(callbackURL);
// Sihirbaz funnel'ı: seçimler yapıldı, listeye giriş kaldı
const funnel = callbackURL.startsWith("/sonuc");
return (
// -mb-28: layout'taki ScrollFlow sarmalayıcısının pb-28'ini nötrler —
// kart tam dvh içinde ortalanır, sayfa scroll etmez (sığmazsa scroll açılır).
<div className="-mb-28 flex min-h-dvh flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-6 py-8">
<div className="relative">
<div className="relative rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
{baglantiHatasi && (
<div
role="alert"
className="mb-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"
>
<p className="font-semibold">
Giriş bağlantısı geçersiz ya da süresi dolmuş
</p>
<p className="mt-1">
Bağlantılar 15 dakika geçerli ve tek kullanımlık. Aşağıdan
yeni bir bağlantı isteyebilirsin.
</p>
</div>
)}
<GirisBaslik funnel={funnel} />
<p className="mt-2 text-sm text-slate-600">
{funnel ? (
<>
Seçimlerin hazır giriş yap,{" "}
<span className="font-semibold">5 deneme kredinle</span> listen
hemen üretilsin.
</>
) : (
<>
Kayıt olduğunda{" "}
<span className="font-semibold">5 deneme kredisi</span>{" "}
hesabına tanımlanır Yapay Zeka danışmana ücretsiz soru sorabilirsin.
</>
)}
</p>
<GirisForm callbackURL={callbackURL} />
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-10 text-primary"
>
<PixelCorner className="absolute -right-4 -top-4 size-9 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -right-4 size-9 rotate-90 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -bottom-4 -left-4 size-9 rotate-180 [&_rect]:[fill-opacity:0.65]" />
<PixelCorner className="absolute -left-4 -top-4 size-9 -rotate-90 [&_rect]:[fill-opacity:0.65]" />
</div>
</div>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla{" "}
<Link href="/gizlilik" className="underline hover:text-slate-700">
gizlilik metnindeki
</Link>{" "}
gibi işlenmesini ve{" "}
<Link href="/kosullar" className="underline hover:text-slate-700">
kullanım koşullarını
</Link>{" "}
kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
</p>
<Suspense fallback={<GirisKartiSkeleton />}>
{searchParams.then(({ callback, hata }) => (
<GirisKarti callback={callback} hata={hata} />
))}
</Suspense>
</main>
</div>
);