diff --git a/src/features/katalog/components/katalog-arama.tsx b/src/features/katalog/components/katalog-arama.tsx index baf4900..e2a7ab1 100644 --- a/src/features/katalog/components/katalog-arama.tsx +++ b/src/features/katalog/components/katalog-arama.tsx @@ -1,8 +1,10 @@ "use client"; import { + startTransition, useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -14,6 +16,7 @@ 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, @@ -114,7 +117,9 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { } const [sorgu, setSorgu] = useState(""); const [sonuclar, setSonuclar] = useState(BOS_SONUCLAR); - const [yukleniyor, setYukleniyor] = useState(false); + // 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. @@ -129,6 +134,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { } | null>(null); const temizSorgu = sorgu.trim(); const aramaAktif = temizSorgu.length >= 2; + const yukleniyor = aramaAktif && cevaplananSorgu !== temizSorgu; const kenarBlurGuncelle = useCallback(() => { const alan = kaydirmaRef.current; @@ -145,16 +151,16 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { 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), ) - .sort( - (a, b) => - eslesmePuani(a.baslik, normalSorgu) - - eslesmePuani(b.baslik, normalSorgu), - ) - .slice(0, REHBER_SONUC_SINIRI); + .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 = @@ -162,31 +168,39 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { 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(); - modalDegisti(!acik); + kisayolTetiklendi(); } } window.addEventListener("keydown", kisayolDinle); return () => window.removeEventListener("keydown", kisayolDinle); - }, [acik]); + }, []); useEffect(() => { if (!acik || !aramaAktif) return; const controller = new AbortController(); const zamanlayici = window.setTimeout(async () => { - setYukleniyor(true); try { - const response = await fetch( + // 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( `/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`, { signal: controller.signal }, ); - if (!response.ok) throw new Error("Arama başarısız"); - const veri = (await response.json()) as AramaSonuclari; - setSonuclar(veri); + // 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 = { @@ -195,10 +209,11 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { }; } catch (hata) { if (!(hata instanceof DOMException && hata.name === "AbortError")) { - setSonuclar(BOS_SONUCLAR); + startTransition(() => { + setSonuclar(BOS_SONUCLAR); + setCevaplananSorgu(temizSorgu); + }); } - } finally { - if (!controller.signal.aborted) setYukleniyor(false); } }, 160); @@ -231,7 +246,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { if (yeniAcik) { setSorgu(""); setSonuclar(BOS_SONUCLAR); - setYukleniyor(false); + setCevaplananSorgu(""); setKenarBlur({ ust: false, alt: false }); } } @@ -397,12 +412,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { ref={inputRef} type="text" value={sorgu} - onChange={(event) => { - const yeniSorgu = event.target.value; - setSorgu(yeniSorgu); - setSonuclar(BOS_SONUCLAR); - setYukleniyor(yeniSorgu.trim().length >= 2); - }} + 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" diff --git a/src/features/liste/components/tercih-profili-kapisi.tsx b/src/features/liste/components/tercih-profili-kapisi.tsx index 47ffb88..44cf238 100644 --- a/src/features/liste/components/tercih-profili-kapisi.tsx +++ b/src/features/liste/components/tercih-profili-kapisi.tsx @@ -28,6 +28,7 @@ import { } from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy"; import type { SihirbazFacetleri } from "@/types/yokatlas"; import { olay, siraKovasi } from "@/lib/analitik"; +import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil"; import { authClient } from "@/lib/auth-client"; @@ -145,12 +146,12 @@ function KapıIcerik({ setYukleniyor(true); setHata(null); try { - const [res, oturum] = await Promise.all([ - fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`), + const [veri, oturum] = await Promise.all([ + onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>( + `/api/facetler?sira=${siraDeger}&tur=${turDeger}`, + ), authClient.getSession().catch(() => null), ]); - if (!res.ok) throw new Error(); - const veri = (await res.json()) as { facetler: SihirbazFacetleri }; setGirisli(Boolean(oturum?.data?.user)); setSira(siraDeger); setTur(turDeger); diff --git a/src/features/pazarlama/components/meraklisina-demo.tsx b/src/features/pazarlama/components/meraklisina-demo.tsx index 6dc4f6e..33040dc 100644 --- a/src/features/pazarlama/components/meraklisina-demo.tsx +++ b/src/features/pazarlama/components/meraklisina-demo.tsx @@ -19,6 +19,7 @@ import { ArrowRight } from "lucide-react"; import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili"; import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler"; import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas"; +import { onbellekliJsonGetir } from "@/lib/istek-cache"; export type DemoVeri = { facetler: SihirbazFacetleri; @@ -48,12 +49,11 @@ async function facetGetir( ): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record } | null> { const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" }); try { - const res = await fetch(`/api/facetler?${params}`, { signal }); - if (!res.ok) return null; - return (await res.json()) as { + // Demo slider'ı aynı sıraya dönünce cache'ten okur (dedup+TTL) + return await onbellekliJsonGetir<{ facetler: SihirbazFacetleri; dilimToplam?: Record; - }; + }>(`/api/facetler?${params}`, { signal }); } catch { return null; } diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 9508c1a..d10d96f 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { startTransition, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Image from "next/image"; @@ -15,6 +15,7 @@ import { } from "@/components/ui/chat-container"; import { Message, MessageContent } from "@/components/ui/message"; import { preloadMarkdown } from "@/components/ui/markdown-lazy"; +import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { PromptInput, PromptInputAction, @@ -101,8 +102,12 @@ export function SohbetClient({ // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; // popup gibi geç mount olan yerlerde eski davranış sürer. if (initialMesajlar != null) return; - fetch("/api/soru") - .then((res) => (res.ok ? res.json() : null)) + // ttlMs: 0 = yalnız in-flight dedup — /sonuc popup'ı ile /listem aynı anda + // mount olursa tek istek; kredi/geçmiş POST sonrası değiştiği için + // TTL cache'i bilinçli kapalı. + onbellekliJsonGetir<{ mesajlar?: Mesaj[]; kredi?: number }>("/api/soru", { + ttlMs: 0, + }) .then((data) => { if (data?.mesajlar) setMesajlar(data.mesajlar); if (typeof data?.kredi === "number") setKredi(data.kredi); @@ -164,8 +169,12 @@ export function SohbetClient({ if (done) break; birikmis += decoder.decode(value, { stream: true }); const anlik = birikmis; - setMesajlar((m) => - m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)), + // Chunk başına gelen güncelleme acil değil: transition'a alınca + // kullanıcı stream sürerken input'a takılmadan yazabilir + startTransition(() => + setMesajlar((m) => + m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)), + ), ); } // Header'daki kredi pill'i layout'ta server-render edilir ve client diff --git a/src/features/sihirbaz/components/cta-sira-form.tsx b/src/features/sihirbaz/components/cta-sira-form.tsx index 9957a62..744e0ce 100644 --- a/src/features/sihirbaz/components/cta-sira-form.tsx +++ b/src/features/sihirbaz/components/cta-sira-form.tsx @@ -27,6 +27,7 @@ import { } from "./sihirbaz-adimlar-lazy"; import { authClient } from "@/lib/auth-client"; import { olay, siraKovasi } from "@/lib/analitik"; +import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili"; import type { SihirbazFacetleri } from "@/types/yokatlas"; import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler"; @@ -91,12 +92,12 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) { try { // Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır // (hero formundaki desenle aynı — bkz. hero-form.tsx). - const [res, oturum] = await Promise.all([ - fetch(`/api/facetler?sira=${value}&tur=${tur}`), + const [veri, oturum] = await Promise.all([ + onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>( + `/api/facetler?sira=${value}&tur=${tur}`, + ), authClient.getSession().catch(() => null), ]); - if (!res.ok) throw new Error(); - const veri = (await res.json()) as { facetler: SihirbazFacetleri }; setGirisli(Boolean(oturum?.data?.user)); setSira(value); setFacetler(veri.facetler); diff --git a/src/features/sihirbaz/components/hero-form.tsx b/src/features/sihirbaz/components/hero-form.tsx index 1f67657..263c496 100644 --- a/src/features/sihirbaz/components/hero-form.tsx +++ b/src/features/sihirbaz/components/hero-form.tsx @@ -18,6 +18,7 @@ import { } from "./sihirbaz-adimlar-lazy"; import { authClient } from "@/lib/auth-client"; import { olay, siraKovasi } from "@/lib/analitik"; +import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { profilDuzenlemeyiAc, useTercihProfili, @@ -67,12 +68,13 @@ export function HeroForm() { try { // Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür: // landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır). - const [res, oturum] = await Promise.all([ - fetch(`/api/facetler?sira=${value}&tur=${tur}`), + // Facetler dedup+TTL cache'li: aynı sıra/tür tekrar girilirse ağa çıkmaz. + const [veri, oturum] = await Promise.all([ + onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>( + `/api/facetler?sira=${value}&tur=${tur}`, + ), authClient.getSession().catch(() => null), ]); - if (!res.ok) throw new Error(); - const veri = (await res.json()) as { facetler: SihirbazFacetleri }; setGirisli(Boolean(oturum?.data?.user)); setSira(value); setFacetler(veri.facetler); diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx index 2197bd3..cbe6df1 100644 --- a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx +++ b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx @@ -7,7 +7,7 @@ // Adımlar kademeli süzülür: 2. adımın illeri seçili kategorilere, 3. adımın // üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir. -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ArrowLeft, ArrowRight, X } from "lucide-react"; import { useReducedMotion } from "motion/react"; import useMeasure from "react-use-measure"; @@ -17,6 +17,7 @@ import { TransitionPanel } from "@/components/core/transition-panel"; import { Button } from "@/components/ui/button"; import { IlSecimHaritasi } from "./il-secim-haritasi"; import { normalizeIlAdi } from "@/lib/harita"; +import { onbellekliJsonGetir } from "@/lib/istek-cache"; import { olay } from "@/lib/analitik"; import { trBaslikDuzeni } from "@/lib/slug"; import type { SihirbazFacetleri } from "@/types/yokatlas"; @@ -85,6 +86,17 @@ export function SihirbazAdimlar({ // tipi sayıları adıma girerken o anki seçimlerle tazelenir. Fetch başarısız // olursa eldeki (daha geniş) liste kalır — sihirbaz asla kilitlenmez. const [canliFacetler, setCanliFacetler] = useState(facetler); + // 81 elemanlı listeyi her çip tıklamasında yeniden sıralamamak/filtrelememek + // için memo; haritaya giden dizinin referansı da sabit kalır (gereksiz + // IlSecimHaritasi render'ı biter). + const siraliIller = useMemo( + () => [...canliFacetler.iller].sort((a, b) => b.adet - a.adet), + [canliFacetler.iller], + ); + const haritaIlleri = useMemo( + () => canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il)), + [canliFacetler.iller], + ); const [facetYukleniyor, setFacetYukleniyor] = useState(false); useEffect(() => { if (adim === 0) return; @@ -94,10 +106,12 @@ export function SihirbazAdimlar({ if (adim === 2) for (const i of iller) params.append("il", i); // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti setFacetYukleniyor(true); - fetch(`/api/facetler?${params}`, { signal: ctrl.signal }) - .then((res) => (res.ok ? res.json() : null)) - .then((veri: { facetler: SihirbazFacetleri } | null) => { - if (!veri) return; + // Cache'li: 2↔3 adımları arasında gidip gelmek aynı URL'i yeniden çekmez + onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>( + `/api/facetler?${params}`, + { signal: ctrl.signal }, + ) + .then((veri) => { setCanliFacetler((mevcut) => ({ ...mevcut, iller: veri.facetler.iller, @@ -250,9 +264,7 @@ export function SihirbazAdimlar({ facetYukleniyor ? "animate-pulse opacity-60" : "" }`} > - {[...canliFacetler.iller] - .sort((a, b) => b.adet - a.adet) - .map((i) => ( + {siraliIller.map((i) => ( HARITA_ILLERI.has(i.il))} + iller={haritaIlleri} secili={iller} onToggle={(il) => listeDegistir(iller, setIller, il, 5)} /> diff --git a/src/features/sihirbaz/sihirbaz-profil.ts b/src/features/sihirbaz/sihirbaz-profil.ts index 77bc42a..d95052c 100644 --- a/src/features/sihirbaz/sihirbaz-profil.ts +++ b/src/features/sihirbaz/sihirbaz-profil.ts @@ -12,16 +12,34 @@ function profilDegistiginiYayinla(): void { window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT)); } +// tercihProfiliOku 18 farklı yerden çağrılıyor ve tek bir "+" tıklaması bile +// 3 kez okuyabiliyor; her çağrıda localStorage + JSON.parse + şema doğrulama +// yapmamak için modül seviyesi cache (js-cache-storage). Geçersizleme iki +// kanaldan: aynı sekmede PROFIL_DEGISTI_EVENT, diğer sekmelerde "storage". +// undefined = henüz okunmadı, null = okundu ve profil yok. +let profilCache: TercihProfili | null | undefined; +if (typeof window !== "undefined") { + window.addEventListener(PROFIL_DEGISTI_EVENT, () => { + profilCache = undefined; + }); + window.addEventListener("storage", (e) => { + if (e.key === SIHIRBAZ_STORAGE_KEY || e.key === null) { + profilCache = undefined; + } + }); +} + /** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */ export function tercihProfiliOku(): TercihProfili | null { if (typeof window === "undefined") return null; + if (profilCache !== undefined) return profilCache; try { const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY); - if (!ham) return null; - return tercihProfiliDogrula(JSON.parse(ham)); + profilCache = ham ? tercihProfiliDogrula(JSON.parse(ham)) : null; } catch { - return null; + profilCache = null; } + return profilCache; } /** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */ @@ -32,6 +50,7 @@ export function tercihProfiliYaz(profil: TercihProfili): void { try { localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru)); } catch {} + profilCache = dogru; profilDegistiginiYayinla(); } @@ -41,6 +60,7 @@ export function tercihProfiliSil(): void { try { localStorage.removeItem(SIHIRBAZ_STORAGE_KEY); } catch {} + profilCache = null; profilDegistiginiYayinla(); } diff --git a/src/lib/istek-cache.ts b/src/lib/istek-cache.ts new file mode 100644 index 0000000..fb359b6 --- /dev/null +++ b/src/lib/istek-cache.ts @@ -0,0 +1,62 @@ +// İstemci tarafı GET istekleri için modül seviyesi in-flight dedup + TTL +// cache. SWR bağımlılığı yerine minimal el yapımı katman: /api/facetler beş +// farklı bileşenden aynı parametrelerle çağrılıyor, katalog araması aynı +// sorguyu tekrar tekrar atıyordu. +// +// - Aynı URL uçuştayken ikinci çağrı aynı promise'i bekler (dedup). +// - Başarılı yanıt ttlMs boyunca bellekte kalır; ttlMs: 0 = yalnız dedup +// (kullanıcıya özel, mutasyon sonrası tazelenmesi gereken uçlar için). +// - Paylaşılan fetch'e caller signal'ı GEÇİLMEZ: bir abonenin iptali +// diğerlerinin isteğini düşürmesin. İptal yalnızca o abonenin bekleyişini +// AbortError ile sonlandırır (katalog aramasının debounce/abort akışı +// davranış değiştirmeden buna bağlanır). + +type Kayit = { zaman: number; veri: unknown }; + +const bellek = new Map(); +const ucusta = new Map>(); +const MAX_KAYIT = 50; +const VARSAYILAN_TTL_MS = 5 * 60_000; + +export async function onbellekliJsonGetir( + url: string, + { + ttlMs = VARSAYILAN_TTL_MS, + signal, + }: { ttlMs?: number; signal?: AbortSignal } = {}, +): Promise { + const kayit = bellek.get(url); + if (kayit && ttlMs > 0 && Date.now() - kayit.zaman < ttlMs) { + return kayit.veri as T; + } + + let soz = ucusta.get(url); + if (!soz) { + soz = fetch(url) + .then(async (res) => { + if (!res.ok) throw new Error(`istek-cache: ${res.status} (${url})`); + const veri: unknown = await res.json(); + if (ttlMs > 0) { + bellek.set(url, { zaman: Date.now(), veri }); + // Basit LRU kırpması: en eski kayıt düşer (insertion order) + if (bellek.size > MAX_KAYIT) { + const enEski = bellek.keys().next().value; + if (enEski !== undefined) bellek.delete(enEski); + } + } + return veri; + }) + .finally(() => ucusta.delete(url)); + ucusta.set(url, soz); + } + + if (!signal) return soz as Promise; + return new Promise((cozul, reddet) => { + const iptalEt = () => reddet(new DOMException("Aborted", "AbortError")); + if (signal.aborted) return iptalEt(); + signal.addEventListener("abort", iptalEt, { once: true }); + (soz as Promise).then(cozul, reddet).finally(() => { + signal.removeEventListener("abort", iptalEt); + }); + }); +}