perf: istemci istek cache'i (dedup+TTL) ve profil cache (Faz 5)
- lib/istek-cache: modül seviyesi in-flight dedup + 5dk TTL cache (SWR bağımlılığı olmadan); caller iptali paylaşılan fetch'i düşürmez - /api/facetler'in 5 çağrı yeri (hero, cta, kapı, sihirbaz adımları, demo) ve /api/katalog-ara cache'e bağlandı — sihirbazda 2↔3 adım geçişi ve tekrarlanan aramalar artık ağa çıkmıyor; /api/soru GET dedup-only (ttl 0) - katalog-arama: elle yukleniyor state'i kalktı, "cevaplanmamış sorgu" türetmesi + startTransition; rehber puanlaması Schwartzian; ⌘K listener'ı useEffectEvent ile tek sefer bağlanıyor - sihirbaz-adimlar: il sort/filter useMemo (çip başına 81 eleman kopyalanıp sıralanıyordu, harita dizisi referansı sabitlendi) - sohbet-client: stream chunk güncellemeleri startTransition'da — cevap akarken input yazımı bloklanmıyor - sihirbaz-profil: modül cache + event/storage geçersizlemesi (tek "+" tıklamasında 3 localStorage okuma + JSON.parse vardı) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
startTransition,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useEffectEvent,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -14,6 +16,7 @@ import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
|
|||||||
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
|
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
|
||||||
import { UniLogo } from "@/components/uni-logo";
|
import { UniLogo } from "@/components/uni-logo";
|
||||||
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
|
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
|
||||||
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import type { RehberAramaKaydi } from "@/lib/rehber";
|
import type { RehberAramaKaydi } from "@/lib/rehber";
|
||||||
import {
|
import {
|
||||||
profilDuzenlemeyiAc,
|
profilDuzenlemeyiAc,
|
||||||
@@ -114,7 +117,9 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
|||||||
}
|
}
|
||||||
const [sorgu, setSorgu] = useState("");
|
const [sorgu, setSorgu] = useState("");
|
||||||
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
|
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(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
|
// Kısayol etiketi (⌘/Ctrl) platforma bağlı sabit bir dış değer: effect'te
|
||||||
// setState yerine useSyncExternalStore — SSR'da ⌘ (eski varsayılan),
|
// setState yerine useSyncExternalStore — SSR'da ⌘ (eski varsayılan),
|
||||||
// hydration sonrası gerçek platform.
|
// hydration sonrası gerçek platform.
|
||||||
@@ -129,6 +134,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const temizSorgu = sorgu.trim();
|
const temizSorgu = sorgu.trim();
|
||||||
const aramaAktif = temizSorgu.length >= 2;
|
const aramaAktif = temizSorgu.length >= 2;
|
||||||
|
const yukleniyor = aramaAktif && cevaplananSorgu !== temizSorgu;
|
||||||
|
|
||||||
const kenarBlurGuncelle = useCallback(() => {
|
const kenarBlurGuncelle = useCallback(() => {
|
||||||
const alan = kaydirmaRef.current;
|
const alan = kaydirmaRef.current;
|
||||||
@@ -145,16 +151,16 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
|||||||
const rehberSonuclari = useMemo(() => {
|
const rehberSonuclari = useMemo(() => {
|
||||||
if (!aramaAktif) return [];
|
if (!aramaAktif) return [];
|
||||||
const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80);
|
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
|
return rehberler
|
||||||
.filter((yazi) =>
|
.filter((yazi) =>
|
||||||
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
|
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
|
||||||
)
|
)
|
||||||
.sort(
|
.map((yazi) => ({ yazi, puan: eslesmePuani(yazi.baslik, normalSorgu) }))
|
||||||
(a, b) =>
|
.sort((a, b) => a.puan - b.puan)
|
||||||
eslesmePuani(a.baslik, normalSorgu) -
|
.slice(0, REHBER_SONUC_SINIRI)
|
||||||
eslesmePuani(b.baslik, normalSorgu),
|
.map(({ yazi }) => yazi);
|
||||||
)
|
|
||||||
.slice(0, REHBER_SONUC_SINIRI);
|
|
||||||
}, [aramaAktif, temizSorgu, rehberler]);
|
}, [aramaAktif, temizSorgu, rehberler]);
|
||||||
|
|
||||||
const sonucVar =
|
const sonucVar =
|
||||||
@@ -162,31 +168,39 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
|||||||
sonuclar.universiteler.length > 0 ||
|
sonuclar.universiteler.length > 0 ||
|
||||||
rehberSonuclari.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(() => {
|
useEffect(() => {
|
||||||
function kisayolDinle(event: KeyboardEvent) {
|
function kisayolDinle(event: KeyboardEvent) {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
modalDegisti(!acik);
|
kisayolTetiklendi();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", kisayolDinle);
|
window.addEventListener("keydown", kisayolDinle);
|
||||||
return () => window.removeEventListener("keydown", kisayolDinle);
|
return () => window.removeEventListener("keydown", kisayolDinle);
|
||||||
}, [acik]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!acik || !aramaAktif) return;
|
if (!acik || !aramaAktif) return;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const zamanlayici = window.setTimeout(async () => {
|
const zamanlayici = window.setTimeout(async () => {
|
||||||
setYukleniyor(true);
|
|
||||||
try {
|
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<AramaSonuclari>(
|
||||||
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
|
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Arama başarısız");
|
// Sonuç listesi acil değil — input yazımıyla yarışmasın
|
||||||
const veri = (await response.json()) as AramaSonuclari;
|
startTransition(() => {
|
||||||
setSonuclar(veri);
|
setSonuclar(veri);
|
||||||
|
setCevaplananSorgu(temizSorgu);
|
||||||
|
});
|
||||||
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
|
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
|
||||||
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
|
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
|
||||||
sonAramaRef.current = {
|
sonAramaRef.current = {
|
||||||
@@ -195,10 +209,11 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
|||||||
};
|
};
|
||||||
} catch (hata) {
|
} catch (hata) {
|
||||||
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
|
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
|
||||||
|
startTransition(() => {
|
||||||
setSonuclar(BOS_SONUCLAR);
|
setSonuclar(BOS_SONUCLAR);
|
||||||
|
setCevaplananSorgu(temizSorgu);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
if (!controller.signal.aborted) setYukleniyor(false);
|
|
||||||
}
|
}
|
||||||
}, 160);
|
}, 160);
|
||||||
|
|
||||||
@@ -231,7 +246,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
|||||||
if (yeniAcik) {
|
if (yeniAcik) {
|
||||||
setSorgu("");
|
setSorgu("");
|
||||||
setSonuclar(BOS_SONUCLAR);
|
setSonuclar(BOS_SONUCLAR);
|
||||||
setYukleniyor(false);
|
setCevaplananSorgu("");
|
||||||
setKenarBlur({ ust: false, alt: false });
|
setKenarBlur({ ust: false, alt: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,12 +412,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={sorgu}
|
value={sorgu}
|
||||||
onChange={(event) => {
|
onChange={(event) => setSorgu(event.target.value)}
|
||||||
const yeniSorgu = event.target.value;
|
|
||||||
setSorgu(yeniSorgu);
|
|
||||||
setSonuclar(BOS_SONUCLAR);
|
|
||||||
setYukleniyor(yeniSorgu.trim().length >= 2);
|
|
||||||
}}
|
|
||||||
placeholder="Bölüm, üniversite veya rehber ara..."
|
placeholder="Bölüm, üniversite veya rehber ara..."
|
||||||
aria-label="Bölüm, üniversite veya rehber ara"
|
aria-label="Bölüm, üniversite veya rehber ara"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
|
} from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
|
||||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||||
import { olay, siraKovasi } from "@/lib/analitik";
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
@@ -145,12 +146,12 @@ function KapıIcerik({
|
|||||||
setYukleniyor(true);
|
setYukleniyor(true);
|
||||||
setHata(null);
|
setHata(null);
|
||||||
try {
|
try {
|
||||||
const [res, oturum] = await Promise.all([
|
const [veri, oturum] = await Promise.all([
|
||||||
fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`),
|
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||||||
|
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
|
||||||
|
),
|
||||||
authClient.getSession().catch(() => null),
|
authClient.getSession().catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
|
||||||
setGirisli(Boolean(oturum?.data?.user));
|
setGirisli(Boolean(oturum?.data?.user));
|
||||||
setSira(siraDeger);
|
setSira(siraDeger);
|
||||||
setTur(turDeger);
|
setTur(turDeger);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ArrowRight } from "lucide-react";
|
|||||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
|
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
|
||||||
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
|
|
||||||
export type DemoVeri = {
|
export type DemoVeri = {
|
||||||
facetler: SihirbazFacetleri;
|
facetler: SihirbazFacetleri;
|
||||||
@@ -48,12 +49,11 @@ async function facetGetir(
|
|||||||
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
||||||
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
|
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/facetler?${params}`, { signal });
|
// Demo slider'ı aynı sıraya dönünce cache'ten okur (dedup+TTL)
|
||||||
if (!res.ok) return null;
|
return await onbellekliJsonGetir<{
|
||||||
return (await res.json()) as {
|
|
||||||
facetler: SihirbazFacetleri;
|
facetler: SihirbazFacetleri;
|
||||||
dilimToplam?: Record<DilimKey, number>;
|
dilimToplam?: Record<DilimKey, number>;
|
||||||
};
|
}>(`/api/facetler?${params}`, { signal });
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { startTransition, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/chat-container";
|
} from "@/components/ui/chat-container";
|
||||||
import { Message, MessageContent } from "@/components/ui/message";
|
import { Message, MessageContent } from "@/components/ui/message";
|
||||||
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
||||||
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
PromptInputAction,
|
PromptInputAction,
|
||||||
@@ -101,8 +102,12 @@ export function SohbetClient({
|
|||||||
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
|
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
|
||||||
// popup gibi geç mount olan yerlerde eski davranış sürer.
|
// popup gibi geç mount olan yerlerde eski davranış sürer.
|
||||||
if (initialMesajlar != null) return;
|
if (initialMesajlar != null) return;
|
||||||
fetch("/api/soru")
|
// ttlMs: 0 = yalnız in-flight dedup — /sonuc popup'ı ile /listem aynı anda
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
// 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) => {
|
.then((data) => {
|
||||||
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
||||||
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
||||||
@@ -164,8 +169,12 @@ export function SohbetClient({
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
birikmis += decoder.decode(value, { stream: true });
|
birikmis += decoder.decode(value, { stream: true });
|
||||||
const anlik = birikmis;
|
const anlik = birikmis;
|
||||||
|
// 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) =>
|
setMesajlar((m) =>
|
||||||
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
|
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Header'daki kredi pill'i layout'ta server-render edilir ve client
|
// Header'daki kredi pill'i layout'ta server-render edilir ve client
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "./sihirbaz-adimlar-lazy";
|
} from "./sihirbaz-adimlar-lazy";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { olay, siraKovasi } from "@/lib/analitik";
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||||
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||||
@@ -91,12 +92,12 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
|||||||
try {
|
try {
|
||||||
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
|
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
|
||||||
// (hero formundaki desenle aynı — bkz. hero-form.tsx).
|
// (hero formundaki desenle aynı — bkz. hero-form.tsx).
|
||||||
const [res, oturum] = await Promise.all([
|
const [veri, oturum] = await Promise.all([
|
||||||
fetch(`/api/facetler?sira=${value}&tur=${tur}`),
|
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||||||
|
`/api/facetler?sira=${value}&tur=${tur}`,
|
||||||
|
),
|
||||||
authClient.getSession().catch(() => null),
|
authClient.getSession().catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
|
||||||
setGirisli(Boolean(oturum?.data?.user));
|
setGirisli(Boolean(oturum?.data?.user));
|
||||||
setSira(value);
|
setSira(value);
|
||||||
setFacetler(veri.facetler);
|
setFacetler(veri.facetler);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "./sihirbaz-adimlar-lazy";
|
} from "./sihirbaz-adimlar-lazy";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { olay, siraKovasi } from "@/lib/analitik";
|
import { olay, siraKovasi } from "@/lib/analitik";
|
||||||
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import {
|
import {
|
||||||
profilDuzenlemeyiAc,
|
profilDuzenlemeyiAc,
|
||||||
useTercihProfili,
|
useTercihProfili,
|
||||||
@@ -67,12 +68,13 @@ export function HeroForm() {
|
|||||||
try {
|
try {
|
||||||
// Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür:
|
// 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).
|
// landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır).
|
||||||
const [res, oturum] = await Promise.all([
|
// Facetler dedup+TTL cache'li: aynı sıra/tür tekrar girilirse ağa çıkmaz.
|
||||||
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),
|
authClient.getSession().catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
|
||||||
setGirisli(Boolean(oturum?.data?.user));
|
setGirisli(Boolean(oturum?.data?.user));
|
||||||
setSira(value);
|
setSira(value);
|
||||||
setFacetler(veri.facetler);
|
setFacetler(veri.facetler);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Adımlar kademeli süzülür: 2. adımın illeri seçili kategorilere, 3. adımın
|
// 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.
|
// ü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 { ArrowLeft, ArrowRight, X } from "lucide-react";
|
||||||
import { useReducedMotion } from "motion/react";
|
import { useReducedMotion } from "motion/react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
@@ -17,6 +17,7 @@ import { TransitionPanel } from "@/components/core/transition-panel";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { IlSecimHaritasi } from "./il-secim-haritasi";
|
import { IlSecimHaritasi } from "./il-secim-haritasi";
|
||||||
import { normalizeIlAdi } from "@/lib/harita";
|
import { normalizeIlAdi } from "@/lib/harita";
|
||||||
|
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import { trBaslikDuzeni } from "@/lib/slug";
|
import { trBaslikDuzeni } from "@/lib/slug";
|
||||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
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
|
// 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.
|
// olursa eldeki (daha geniş) liste kalır — sihirbaz asla kilitlenmez.
|
||||||
const [canliFacetler, setCanliFacetler] = useState(facetler);
|
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);
|
const [facetYukleniyor, setFacetYukleniyor] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adim === 0) return;
|
if (adim === 0) return;
|
||||||
@@ -94,10 +106,12 @@ export function SihirbazAdimlar({
|
|||||||
if (adim === 2) for (const i of iller) params.append("il", i);
|
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
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||||||
setFacetYukleniyor(true);
|
setFacetYukleniyor(true);
|
||||||
fetch(`/api/facetler?${params}`, { signal: ctrl.signal })
|
// Cache'li: 2↔3 adımları arasında gidip gelmek aynı URL'i yeniden çekmez
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||||||
.then((veri: { facetler: SihirbazFacetleri } | null) => {
|
`/api/facetler?${params}`,
|
||||||
if (!veri) return;
|
{ signal: ctrl.signal },
|
||||||
|
)
|
||||||
|
.then((veri) => {
|
||||||
setCanliFacetler((mevcut) => ({
|
setCanliFacetler((mevcut) => ({
|
||||||
...mevcut,
|
...mevcut,
|
||||||
iller: veri.facetler.iller,
|
iller: veri.facetler.iller,
|
||||||
@@ -250,9 +264,7 @@ export function SihirbazAdimlar({
|
|||||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{[...canliFacetler.iller]
|
{siraliIller.map((i) => (
|
||||||
.sort((a, b) => b.adet - a.adet)
|
|
||||||
.map((i) => (
|
|
||||||
<Cip
|
<Cip
|
||||||
key={i.il}
|
key={i.il}
|
||||||
secili={iller.includes(i.il)}
|
secili={iller.includes(i.il)}
|
||||||
@@ -286,7 +298,7 @@ export function SihirbazAdimlar({
|
|||||||
{/* Harita geniş ekranda görsel destek olarak kalır */}
|
{/* Harita geniş ekranda görsel destek olarak kalır */}
|
||||||
<div className="mt-4 hidden sm:block">
|
<div className="mt-4 hidden sm:block">
|
||||||
<IlSecimHaritasi
|
<IlSecimHaritasi
|
||||||
iller={canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
|
iller={haritaIlleri}
|
||||||
secili={iller}
|
secili={iller}
|
||||||
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
|
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,16 +12,34 @@ function profilDegistiginiYayinla(): void {
|
|||||||
window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT));
|
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. */
|
/** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */
|
||||||
export function tercihProfiliOku(): TercihProfili | null {
|
export function tercihProfiliOku(): TercihProfili | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
if (profilCache !== undefined) return profilCache;
|
||||||
try {
|
try {
|
||||||
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
||||||
if (!ham) return null;
|
profilCache = ham ? tercihProfiliDogrula(JSON.parse(ham)) : null;
|
||||||
return tercihProfiliDogrula(JSON.parse(ham));
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
profilCache = null;
|
||||||
}
|
}
|
||||||
|
return profilCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */
|
/** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */
|
||||||
@@ -32,6 +50,7 @@ export function tercihProfiliYaz(profil: TercihProfili): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
|
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
profilCache = dogru;
|
||||||
profilDegistiginiYayinla();
|
profilDegistiginiYayinla();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +60,7 @@ export function tercihProfiliSil(): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
|
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
profilCache = null;
|
||||||
profilDegistiginiYayinla();
|
profilDegistiginiYayinla();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
src/lib/istek-cache.ts
Normal file
62
src/lib/istek-cache.ts
Normal file
@@ -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<string, Kayit>();
|
||||||
|
const ucusta = new Map<string, Promise<unknown>>();
|
||||||
|
const MAX_KAYIT = 50;
|
||||||
|
const VARSAYILAN_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
export async function onbellekliJsonGetir<T>(
|
||||||
|
url: string,
|
||||||
|
{
|
||||||
|
ttlMs = VARSAYILAN_TTL_MS,
|
||||||
|
signal,
|
||||||
|
}: { ttlMs?: number; signal?: AbortSignal } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
return new Promise<T>((cozul, reddet) => {
|
||||||
|
const iptalEt = () => reddet(new DOMException("Aborted", "AbortError"));
|
||||||
|
if (signal.aborted) return iptalEt();
|
||||||
|
signal.addEventListener("abort", iptalEt, { once: true });
|
||||||
|
(soz as Promise<T>).then(cozul, reddet).finally(() => {
|
||||||
|
signal.removeEventListener("abort", iptalEt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user