Files
kolaytercih/src/features/katalog/components/katalog-arama.tsx
bilalgursen 7dff241eb6 feat(tasarim): ana sayfa ve her sayfadaki ortak parçalar dile göre
- Landing kartlarının hover'ı tek turuncu (§4.4)
- Güven bandında sayı üstte ve iri, etiket altında (§1.1)
- "Dürüstlük notu", alt bilgi başlıkları, arama penceresi başlıkları
  büyük harften çıktı; 10-11 px yazılar 12 px'e
- Alt bilgi linklerinin hover'ı turuncu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:05:56 +03:00

797 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
startTransition,
useCallback,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import type { RehberAramaKaydi } from "@/lib/rehber";
import {
profilDuzenlemeyiAc,
sonucaGitIste,
useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili";
import { preloadTercihProfiliKapisi } from "@/features/liste/components/tercih-profili-kapisi-lazy";
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { olay } from "@/lib/analitik";
import {
ArrowRight,
BookOpenText,
Building2,
GraduationCap,
Search,
SquarePen,
Trophy,
X,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
type BolumSonucu = {
href: string;
ad: string;
programSayisi: number;
enIyiSira: number | null;
seviye: string;
};
type UniversiteSonucu = {
href: string;
ad: string;
il: string | null;
tur: string | null;
programSayisi: number;
fakulteSayisi: number;
};
type AramaSonuclari = {
bolumler: BolumSonucu[];
universiteler: UniversiteSonucu[];
};
const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
const REHBER_SONUC_SINIRI = 4;
const sayi = (deger: number) => deger.toLocaleString("tr-TR");
// Platform değişmez — abonelik gerektirmeyen dış değer (useSyncExternalStore)
const bosAbonelik = () => () => {};
const macMiSunucu = () => true;
function macMiOku(): boolean {
const platform =
(navigator as { userAgentData?: { platform?: string } }).userAgentData
?.platform ??
navigator.platform ??
"";
return /mac|iphone|ipad/i.test(platform);
}
const KESFET_TONLARI = {
slate: {
pencere: "border-slate-200/80 bg-white",
nokta: "bg-slate-300",
rozet: "border-slate-200 bg-slate-50 text-slate-600",
cizgi: "bg-slate-200/90",
},
sky: {
pencere: "border-sky-200/70 bg-white",
nokta: "bg-sky-300",
rozet: "border-sky-200 bg-sky-50 text-sky-600",
cizgi: "bg-sky-100",
},
amber: {
pencere: "border-amber-200/70 bg-white",
nokta: "bg-amber-300",
rozet: "border-amber-200 bg-amber-50 text-amber-600",
cizgi: "bg-amber-100",
},
} as const;
export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
const router = useRouter();
const profil = useTercihProfili();
const [acik, setAcik] = useState(false);
function siralamaGirVeyaSonucaGit() {
// Navbar CTA'sı "Sıralamanı gir" dediği için kapı tanıtım adımını
// atlayıp doğrudan başarı sıralaması adımıyla açılır.
const { href } = sonucaGitIste({ dogrudanSira: true });
if (href) router.push(href);
}
const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
// Yükleniyor ayrı state değil türetme: "yazılan sorgu henüz cevaplanmadı".
// onChange'de elle iskelet açıp fetch finally'sinde kapatma dansı kalktı.
const [cevaplananSorgu, setCevaplananSorgu] = useState("");
// Kısayol etiketi (⌘/Ctrl) platforma bağlı sabit bir dış değer: effect'te
// setState yerine useSyncExternalStore — SSR'da ⌘ (eski varsayılan),
// hydration sonrası gerçek platform.
const macMi = useSyncExternalStore(bosAbonelik, macMiOku, macMiSunucu);
const inputRef = useRef<HTMLInputElement>(null);
const kaydirmaRef = useRef<HTMLDivElement>(null);
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
// Kapanışta gönderilecek son arama özeti (bkz. aşağıdaki fetch efekti)
const sonAramaRef = useRef<{
sorgu_uzunlugu: number;
sonuc_sayisi: number;
} | null>(null);
const temizSorgu = sorgu.trim();
const aramaAktif = temizSorgu.length >= 2;
const yukleniyor = aramaAktif && cevaplananSorgu !== temizSorgu;
const kenarBlurGuncelle = useCallback(() => {
const alan = kaydirmaRef.current;
if (!alan) return;
const ust = alan.scrollTop > 4;
const alt = alan.scrollTop + alan.clientHeight < alan.scrollHeight - 4;
setKenarBlur((onceki) =>
onceki.ust === ust && onceki.alt === alt ? onceki : { ust, alt },
);
}, []);
// Rehber özetleri build sırasında server'dan prop olarak gelir (~10 yazı);
// arama client'ta yapılır — prod imajında content/ dizini bulunmaz.
const rehberSonuclari = useMemo(() => {
if (!aramaAktif) return [];
const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80);
// Puan öğe başına BİR kez hesaplanır (Schwartzian) — sort comparator'ı
// her karşılaştırmada eslesmePuani->aramaNormalize çalıştırıyordu
return rehberler
.filter((yazi) =>
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
)
.map((yazi) => ({ yazi, puan: eslesmePuani(yazi.baslik, normalSorgu) }))
.sort((a, b) => a.puan - b.puan)
.slice(0, REHBER_SONUC_SINIRI)
.map(({ yazi }) => yazi);
}, [aramaAktif, temizSorgu, rehberler]);
const sonucVar =
sonuclar.bolumler.length > 0 ||
sonuclar.universiteler.length > 0 ||
rehberSonuclari.length > 0;
// Listener bir kez bağlanır; güncel `acik` useEffectEvent içinden okunur —
// modal her açılıp kapandığında remove/add döngüsü dönmesin
// (client-event-listeners)
const kisayolTetiklendi = useEffectEvent(() => modalDegisti(!acik));
useEffect(() => {
function kisayolDinle(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
kisayolTetiklendi();
}
}
window.addEventListener("keydown", kisayolDinle);
return () => window.removeEventListener("keydown", kisayolDinle);
}, []);
useEffect(() => {
if (!acik || !aramaAktif) return;
const controller = new AbortController();
const zamanlayici = window.setTimeout(async () => {
try {
// Dedup+TTL cache: aynı sorgu ("psikoloji" yazıp silip tekrar yazan
// kullanıcı) ağa çıkmadan bellekten döner. İptal yalnızca bu
// bekleyişi düşürür; paylaşılan fetch sürer.
const veri = await onbellekliJsonGetir<AramaSonuclari>(
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
{ signal: controller.signal },
);
// Sonuç listesi acil değil — input yazımıyla yarışmasın
startTransition(() => {
setSonuclar(veri);
setCevaplananSorgu(temizSorgu);
});
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
sonAramaRef.current = {
sorgu_uzunlugu: temizSorgu.length,
sonuc_sayisi: veri.bolumler.length + veri.universiteler.length,
};
} catch (hata) {
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
startTransition(() => {
setSonuclar(BOS_SONUCLAR);
setCevaplananSorgu(temizSorgu);
});
}
}
}, 160);
return () => {
window.clearTimeout(zamanlayici);
controller.abort();
};
}, [acik, aramaAktif, temizSorgu]);
// Sonuçlar, logolar ve kapak görselleri yüklendikçe scrollHeight değişir;
// kenar blur görünürlüğünü scroll olayına ek olarak boyut değişiminde de tazele.
useEffect(() => {
if (!acik) return;
const alan = kaydirmaRef.current;
if (!alan) return;
kenarBlurGuncelle();
const gozlemci = new ResizeObserver(kenarBlurGuncelle);
gozlemci.observe(alan);
for (const cocuk of alan.children) gozlemci.observe(cocuk);
return () => gozlemci.disconnect();
}, [acik, aramaAktif, yukleniyor, sonucVar, kenarBlurGuncelle]);
// Temizlik kapanışta değil açılışta yapılır: kapanış animasyonu sırasında
// içerik "Keşfet" ekranına zıplamaz, modal her açılışta temiz başlar.
// (X / sonuç linki `kapat` ile kapatır; orada temizlik yapılmadığı için
// eski sorgu, bayat sonuçlar ve yarıda kesilen fetch'in takılı kalan
// yükleniyor iskeleti bir sonraki açılışa taşınıyordu.)
function modalDegisti(yeniAcik: boolean) {
setAcik(yeniAcik);
if (yeniAcik) {
setSorgu("");
setSonuclar(BOS_SONUCLAR);
setCevaplananSorgu("");
setKenarBlur({ ust: false, alt: false });
}
}
// Arama oturumu kapandığında tek bir olay — hiç arama yapılmadıysa sessiz
useEffect(() => {
if (acik) return;
const son = sonAramaRef.current;
if (!son) return;
sonAramaRef.current = null;
olay("katalog_arama", son);
}, [acik]);
function kapat() {
setAcik(false);
}
return (
<>
{/* lg altında logo + sağ küme ile çakışır (absolute); o aralıkta gizli */}
<nav className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 lg:flex">
{profil ? (
<button
type="button"
onClick={profilDuzenlemeyiAc}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"
>
<Trophy className="size-4 shrink-0 text-orange-500" aria-hidden />
<span className="font-semibold tracking-tight text-slate-900">
<span className="tabular-nums">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="font-medium text-slate-500">
{" "}
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</span>
<span
aria-hidden
className="flex max-w-0 items-center gap-1 overflow-hidden rounded-full bg-slate-100 px-0 py-1.5 text-xs font-medium text-slate-500 opacity-0 transition-[max-width,padding,opacity,background-color,color] duration-200 group-hover/profil:max-w-28 group-hover/profil:bg-white group-hover/profil:px-2.5 group-hover/profil:text-slate-900 group-hover/profil:opacity-100 group-focus-visible/profil:max-w-28 group-focus-visible/profil:bg-white group-focus-visible/profil:px-2.5 group-focus-visible/profil:text-slate-900 group-focus-visible/profil:opacity-100"
>
<SquarePen className="size-3 shrink-0" aria-hidden />
Değiştir
</span>
</button>
) : (
<>
<button
type="button"
onClick={siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
<Trophy className="size-4 text-orange-500" aria-hidden />
Sıralamanı gir
</button>
<Link
href="/#nasil-calisir"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Nasıl çalışır?
</Link>
</>
)}
<button
type="button"
onClick={() => modalDegisti(true)}
className="flex cursor-pointer items-center gap-2 rounded-full bg-slate-900 px-5 py-2 text-white transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
>
<Search className="size-4" aria-hidden />
Ara
<KbdGroup aria-hidden className="ml-1">
<Kbd className="bg-white/15 text-white/85">
{macMi ? "⌘" : "Ctrl"}
</Kbd>
<Kbd className="bg-white/15 text-white/85">K</Kbd>
</KbdGroup>
</button>
</nav>
{/* lg altında 2. satır: sıralama + ara (header flex-wrap + order-3) */}
<div className="order-3 flex w-full basis-full items-center gap-1.5 lg:hidden">
{profil ? (
<button
type="button"
onClick={profilDuzenlemeyiAc}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm"
>
<Trophy
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
aria-hidden
/>
<span className="truncate tracking-tight">
<span className="tabular-nums">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="font-medium text-slate-500">
{" "}
· {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</span>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500">
<SquarePen className="size-3" aria-hidden />
Değiştir
</span>
</button>
) : (
<button
type="button"
onClick={siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label="Başarı sıralamanı gir"
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm"
>
<Trophy
className="size-3.5 shrink-0 text-orange-500 sm:size-4"
aria-hidden
/>
<span className="truncate">Sıralama gir</span>
</button>
)}
<button
type="button"
onClick={() => modalDegisti(true)}
aria-label="Bölüm, üniversite veya rehber ara"
className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:size-12"
>
<Search className="size-4" aria-hidden />
</button>
</div>
<Dialog open={acik} onOpenChange={modalDegisti}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
className="max-h-[min(720px,calc(100dvh-2rem))] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden rounded-2xl bg-white p-0 duration-200 sm:max-w-2xl motion-reduce:data-open:zoom-in-100 motion-reduce:data-closed:zoom-out-100"
>
<DialogTitle className="sr-only">
Bölüm, üniversite ve rehber ara
</DialogTitle>
<DialogDescription className="sr-only">
YKS bölümlerini, üniversiteleri ve rehber yazılarını tek yerden
ara.
</DialogDescription>
<div className="flex items-center border-b border-slate-200 px-4 sm:px-5">
<Search
className="mr-3 size-5 shrink-0 text-slate-400"
aria-hidden
/>
<input
ref={inputRef}
type="text"
value={sorgu}
onChange={(event) => setSorgu(event.target.value)}
placeholder="Bölüm, üniversite veya rehber ara..."
aria-label="Bölüm, üniversite veya rehber ara"
autoComplete="off"
className="h-16 min-w-0 flex-1 bg-transparent text-base text-slate-950 outline-none placeholder:text-slate-400 sm:h-18 sm:text-lg"
/>
<button
type="button"
onClick={kapat}
aria-label="Aramayı kapat"
className="flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<X className="size-4" aria-hidden />
</button>
</div>
<div className="relative min-h-0">
<div
ref={kaydirmaRef}
onScroll={kenarBlurGuncelle}
className="h-full max-h-full min-h-72 overflow-y-auto overscroll-contain p-3 sm:p-4"
aria-live="polite"
>
{!aramaAktif ? (
<BaslangicIcerigi onSec={kapat} />
) : yukleniyor ? (
<AramaIskeleti />
) : sonucVar ? (
<div className="space-y-5">
{sonuclar.bolumler.length > 0 ? (
<SonucGrubu
baslik="Bölümler"
ikon={<GraduationCap className="size-4" aria-hidden />}
>
{sonuclar.bolumler.map((bolum) => (
<SonucLinki
key={bolum.href}
href={bolum.href}
baslik={bolum.ad}
meta={`${bolum.seviye} · ${sayi(bolum.programSayisi)} program`}
detay={
bolum.enIyiSira
? `En iyi sıra ${sayi(bolum.enIyiSira)}`
: "Sıralama verisi yok"
}
onSec={kapat}
/>
))}
</SonucGrubu>
) : null}
{sonuclar.universiteler.length > 0 ? (
<SonucGrubu
baslik="Üniversiteler"
ikon={<Building2 className="size-4" aria-hidden />}
>
{sonuclar.universiteler.map((universite) => (
<SonucLinki
key={universite.href}
href={universite.href}
gorsel={
<UniLogo
slug={universite.href.split("/").pop() ?? ""}
ad={universite.ad}
boy="sm"
/>
}
baslik={universite.ad}
meta={
[universite.il, universite.tur]
.filter(Boolean)
.join(" · ") || "Üniversite"
}
detay={`${sayi(universite.programSayisi)} program · ${sayi(universite.fakulteSayisi)} fakülte`}
onSec={kapat}
/>
))}
</SonucGrubu>
) : null}
{rehberSonuclari.length > 0 ? (
<SonucGrubu
baslik="Rehber yazıları"
ikon={<BookOpenText className="size-4" aria-hidden />}
>
<div className="grid gap-2 sm:grid-cols-2">
{rehberSonuclari.map((yazi) => (
<RehberSonucKarti
key={yazi.slug}
yazi={yazi}
onSec={kapat}
/>
))}
</div>
</SonucGrubu>
) : null}
</div>
) : (
<div className="flex min-h-64 flex-col items-center justify-center px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-500">
<Search className="size-5" aria-hidden />
</div>
<p className="mt-4 font-medium text-slate-900">
&quot;{temizSorgu}&quot; için sonuç bulunamadı
</p>
<p className="mt-1 max-w-sm text-sm leading-6 text-slate-500">
Bölüm, üniversite veya rehber konusunu farklı yazarak tekrar
dene.
</p>
</div>
)}
</div>
<div
aria-hidden
className={`pointer-events-none absolute inset-x-0 top-0 h-14 transition-opacity duration-200 ${kenarBlur.ust ? "opacity-100" : "opacity-0"}`}
>
<ProgressiveBlur
position="top"
backgroundColor="#ffffff"
height="56px"
blurAmount="4px"
/>
</div>
<div
aria-hidden
className={`pointer-events-none absolute inset-x-0 bottom-0 h-14 transition-opacity duration-200 ${kenarBlur.alt ? "opacity-100" : "opacity-0"}`}
>
<ProgressiveBlur
position="bottom"
backgroundColor="#ffffff"
height="56px"
blurAmount="4px"
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-slate-200 bg-slate-50/80 px-4 py-3 text-xs text-slate-500 sm:px-5">
<span>En az 2 harf yazarak ara</span>
<span className="hidden items-center gap-1.5 sm:flex">
<Kbd>Esc</Kbd> ile kapat
</span>
</div>
</DialogContent>
</Dialog>
</>
);
}
function BaslangicIcerigi({ onSec }: { onSec: () => void }) {
return (
<div>
<p className="px-1 pb-2.5 text-sm font-semibold text-slate-700">
Keşfet
</p>
<div className="grid grid-cols-3 gap-2">
<KesfetLinki
href="/bolumler"
ikon={GraduationCap}
baslik="Tüm bölümler"
kisaBaslik="Bölümler"
aciklama="Lisans · önlisans"
ton="slate"
onSec={onSec}
/>
<KesfetLinki
href="/universiteler"
ikon={Building2}
baslik="Üniversiteler"
kisaBaslik="Üniversite"
aciklama="Devlet · vakıf"
ton="sky"
onSec={onSec}
/>
<KesfetLinki
href="/rehber"
ikon={BookOpenText}
baslik="Tercih rehberi"
kisaBaslik="Rehber"
aciklama="Sorularına yanıt"
ton="amber"
onSec={onSec}
/>
</div>
<div className="mt-5 flex gap-3 rounded-2xl bg-slate-50 px-3.5 py-3.5 sm:px-4">
<span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-white text-slate-400 shadow-[0_0_0_1px_rgba(15,23,42,0.06)]">
<Search className="size-3.5" aria-hidden />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold tracking-tight text-slate-900">
Neyi merak ediyorsun?
</p>
<p className="mt-0.5 text-xs leading-5 text-slate-500 sm:text-sm sm:leading-6">
“Psikoloji” veya “Boğaziçi” yaz; eşleşen sayfaları ve temel
bilgilerini burada önizle.
</p>
</div>
</div>
<AramaFunnelKarti onSec={onSec} />
</div>
);
}
function KesfetLinki({
href,
ikon: Ikon,
baslik,
kisaBaslik,
aciklama,
ton,
onSec,
}: {
href: string;
ikon: typeof GraduationCap;
baslik: string;
kisaBaslik: string;
aciklama: string;
ton: keyof typeof KESFET_TONLARI;
onSec: () => void;
}) {
const palet = KESFET_TONLARI[ton];
return (
<Link
href={href}
onClick={onSec}
className="group/kesfet flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white transition-transform duration-150 ease-out active:scale-[0.97]"
>
{/* Mini tarayıcı — sekme içinde tek büyük ikon, alta doğru maske ile solar */}
<div
aria-hidden
className="relative h-18 overflow-hidden bg-slate-50/80 sm:h-22"
>
<div
className={`absolute inset-x-2 -bottom-3 top-2 flex flex-col overflow-hidden rounded-t-xl border border-b-0 sm:inset-x-2.5 sm:top-2.5 ${palet.pencere}`}
>
<div className="flex h-3.5 shrink-0 items-center gap-1 border-b border-slate-100/90 bg-slate-50/90 px-2">
<span className={`size-1.5 rounded-full ${palet.nokta}`} />
<span className={`size-1.5 rounded-full ${palet.nokta} opacity-65`} />
<span className={`size-1.5 rounded-full ${palet.nokta} opacity-35`} />
<span className="ml-1.5 h-1.5 max-w-[55%] flex-1 rounded-full bg-white shadow-[0_0_0_1px_rgba(15,23,42,0.06)]" />
</div>
<div className="relative min-h-0 flex-1 overflow-hidden">
<div className="absolute inset-x-0 top-0 flex flex-col items-center gap-1.5 pt-2 sm:gap-2 sm:pt-2.5">
<span
className={`flex size-9 items-center justify-center rounded-xl border shadow-[0_6px_12px_-6px_rgba(15,23,42,0.18)] transition-transform duration-200 ease-out group-hover/kesfet:-translate-y-0.5 sm:size-10 ${palet.rozet}`}
>
<Ikon className="size-4.5 sm:size-5" strokeWidth={1.75} />
</span>
<span className={`h-1.5 w-2/5 rounded-full ${palet.cizgi}`} />
<span className="h-1.5 w-1/4 rounded-full bg-slate-100" />
</div>
<ProgressiveBlur
position="bottom"
backgroundColor="#ffffff"
height="45%"
blurAmount="2px"
/>
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-white to-transparent" />
</div>
<span className="min-w-0 px-2.5 pb-3 pt-2 text-center sm:px-3.5 sm:pb-3.5 sm:pt-2.5 sm:text-left">
<span className="block text-sm font-semibold leading-tight tracking-tight text-slate-900">
<span className="sm:hidden">{kisaBaslik}</span>
<span className="hidden sm:inline">{baslik}</span>
</span>
<span className="mt-0.5 block text-xs leading-snug text-slate-500">
{aciklama}
</span>
</span>
</Link>
);
}
function SonucGrubu({
baslik,
ikon,
children,
}: {
baslik: string;
ikon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section>
<h3 className="mb-2 flex items-center gap-2 px-2 text-sm font-semibold text-slate-700">
{ikon}
{baslik}
</h3>
<div className="space-y-1">{children}</div>
</section>
);
}
function SonucLinki({
href,
baslik,
meta,
detay,
gorsel,
onSec,
}: {
href: string;
baslik: string;
meta: string;
detay: string;
gorsel?: React.ReactNode;
onSec: () => void;
}) {
return (
<Link
href={href}
onClick={onSec}
className="group flex items-center gap-3 rounded-xl px-3 py-3 transition-colors duration-150 hover:bg-slate-100"
>
{gorsel}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-900">
{baslik}
</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{meta}
</span>
</span>
<span className="hidden shrink-0 text-right text-xs text-slate-400 sm:block">
{detay}
</span>
<ArrowRight
className="size-4 shrink-0 text-slate-300 transition-[color,transform] duration-150 group-hover:translate-x-0.5 group-hover:text-slate-600"
aria-hidden
/>
</Link>
);
}
function RehberSonucKarti({
yazi,
onSec,
}: {
yazi: RehberAramaKaydi;
onSec: () => void;
}) {
return (
<Link
href={`/rehber/${yazi.slug}`}
onClick={onSec}
className="group overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 transition-[border-color,background-color,transform] duration-150 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.99]"
>
<RehberKapak baslik={yazi.baslik} compact className="rounded-lg" />
<span className="block px-2 pb-1.5 pt-2.5">
<span className="line-clamp-2 text-xs leading-5 text-slate-600">
{yazi.aciklama}
</span>
<span className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-primary">
Yazıyı oku
<ArrowRight
className="size-3.5 transition-transform duration-150 group-hover:translate-x-0.5"
aria-hidden
/>
</span>
</span>
</Link>
);
}
function AramaIskeleti() {
return (
<div className="space-y-2 px-2 pt-2" aria-label="Aranıyor">
{[0, 1, 2, 3].map((sira) => (
<div
key={sira}
className="flex h-16 animate-pulse items-center rounded-xl bg-slate-100 px-3 motion-reduce:animate-none"
>
<div className="h-4 w-2/5 rounded bg-slate-200" />
</div>
))}
</div>
);
}