- 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>
252 lines
8.8 KiB
TypeScript
252 lines
8.8 KiB
TypeScript
"use client";
|
||
|
||
// /meraklisina'nın canlı anlatım katmanı: kullanıcının kayıtlı tercih
|
||
// profilindeki sıralama + puan türü okunur, dilim şeridi o sıralamanın GERÇEK
|
||
// sayılarıyla güncellenir. Profil yoksa sayfa SSR'daki örnek sıralamayla kalır.
|
||
// Provider sayfadaki metin bölümlerini (RSC children) sarar; görseller
|
||
// context'ten okur. (Sihirbaz hunisi statik bir şemayla anlatılır — bkz.
|
||
// huni-semasi.tsx.)
|
||
|
||
import {
|
||
createContext,
|
||
useContext,
|
||
useEffect,
|
||
useState,
|
||
type ReactNode,
|
||
} from "react";
|
||
import Link from "next/link";
|
||
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;
|
||
dilimToplam: Record<DilimKey, number>;
|
||
};
|
||
|
||
type DemoDurum = {
|
||
sira: number;
|
||
tur: string;
|
||
veri: DemoVeri;
|
||
yukleniyor: boolean;
|
||
profilVar: boolean;
|
||
};
|
||
|
||
const DemoContext = createContext<DemoDurum | null>(null);
|
||
|
||
function useDemo(): DemoDurum {
|
||
const ctx = useContext(DemoContext);
|
||
if (!ctx) throw new Error("Demo bileşenleri SiralamaDemo içinde kullanılmalı");
|
||
return ctx;
|
||
}
|
||
|
||
async function facetGetir(
|
||
sira: number,
|
||
tur: string,
|
||
signal: AbortSignal,
|
||
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
||
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
|
||
try {
|
||
// Demo slider'ı aynı sıraya dönünce cache'ten okur (dedup+TTL)
|
||
return await onbellekliJsonGetir<{
|
||
facetler: SihirbazFacetleri;
|
||
dilimToplam?: Record<DilimKey, number>;
|
||
}>(`/api/facetler?${params}`, { signal });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function SiralamaDemo({
|
||
baslangicSira,
|
||
baslangicVeri,
|
||
children,
|
||
}: {
|
||
baslangicSira: number;
|
||
baslangicVeri: DemoVeri;
|
||
children: ReactNode;
|
||
}) {
|
||
const profil = useTercihProfili();
|
||
const sira = profil?.sira ?? baslangicSira;
|
||
const tur = profil?.tur ?? "say";
|
||
const [veri, setVeri] = useState(baslangicVeri);
|
||
const [yukleniyor, setYukleniyor] = useState(false);
|
||
|
||
// Profil yüklendi ve SSR örneğinden farklı → ana veriyi tazele
|
||
useEffect(() => {
|
||
if (sira === baslangicSira && tur === "say") return;
|
||
const ctrl = new AbortController();
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||
setYukleniyor(true);
|
||
facetGetir(sira, tur, ctrl.signal)
|
||
.then((cevap) => {
|
||
if (!cevap?.dilimToplam) return;
|
||
setVeri({ facetler: cevap.facetler, dilimToplam: cevap.dilimToplam });
|
||
})
|
||
.finally(() => setYukleniyor(false));
|
||
return () => ctrl.abort();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [sira, tur]);
|
||
|
||
const durum: DemoDurum = {
|
||
sira,
|
||
tur,
|
||
veri,
|
||
yukleniyor,
|
||
profilVar: profil !== null,
|
||
};
|
||
|
||
return <DemoContext.Provider value={durum}>{children}</DemoContext.Provider>;
|
||
}
|
||
|
||
/** Görsellerin hangi sıralamayla hesaplandığını söyleyen bilgi kartı. */
|
||
export function DemoKumanda() {
|
||
const { sira, tur, yukleniyor, profilVar } = useDemo();
|
||
const turEtiket = PUAN_TURU_ETIKET[tur] ?? tur;
|
||
return (
|
||
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-5">
|
||
<p className="font-heading text-sm font-bold text-slate-900">
|
||
{profilVar
|
||
? "Görseller senin sıralamana göre hesaplandı"
|
||
: "Görseller örnek bir sıralamayla gösteriliyor"}
|
||
</p>
|
||
<p
|
||
className={`mt-2 text-sm text-slate-600 ${yukleniyor ? "animate-pulse" : ""}`}
|
||
aria-live="polite"
|
||
>
|
||
{yukleniyor ? (
|
||
"Hesaplanıyor…"
|
||
) : (
|
||
<>
|
||
<span className="font-semibold tabular-nums text-slate-900">
|
||
{sira.toLocaleString("tr-TR")}.
|
||
</span>{" "}
|
||
sıra · {turEtiket} — aşağıdaki tüm sayılar bu aday için gerçek YÖK
|
||
Atlas verisidir.
|
||
{!profilVar &&
|
||
" Ana sayfadan kendi sıralamanı girersen bu sayfa sana göre güncellenir."}
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Sıralama ekseni: adayın sırasına göre dilim sınırları ve gerçek adetler. */
|
||
export function DilimSeridiCanli() {
|
||
const { sira, veri, yukleniyor } = useDemo();
|
||
const t = veri.dilimToplam;
|
||
const dengeliUst = Math.round(sira * 1.4);
|
||
const fmt = (n: number) => n.toLocaleString("tr-TR");
|
||
|
||
return (
|
||
<figure className={`mt-6 ${yukleniyor ? "animate-pulse opacity-60" : ""}`}>
|
||
<svg
|
||
viewBox="0 0 640 168"
|
||
role="img"
|
||
aria-label={`Sıralama ekseni: ${fmt(sira)}. sıradaki aday için Hayal diliminde ${fmt(t.hayal)}, Dengeli diliminde ${fmt(t.dengeli)}, Garanti diliminde ${fmt(t.garanti)} program var.`}
|
||
className="w-full"
|
||
>
|
||
{/* eksen */}
|
||
<line
|
||
x1="20"
|
||
y1="86"
|
||
x2="620"
|
||
y2="86"
|
||
stroke="currentColor"
|
||
className="text-slate-300"
|
||
strokeWidth="2"
|
||
/>
|
||
<text x="20" y="150" className="fill-slate-400 text-[11px]">
|
||
1. sıra (en iyi)
|
||
</text>
|
||
<text
|
||
x="620"
|
||
y="150"
|
||
textAnchor="end"
|
||
className="fill-slate-400 text-[11px]"
|
||
>
|
||
taban sıralaması büyüdükçe →
|
||
</text>
|
||
|
||
{/* hayal */}
|
||
<rect x="20" y="70" width="150" height="32" rx="4" className="fill-red-100" />
|
||
<text x="95" y="40" textAnchor="middle" className="fill-red-600 text-[12px] font-semibold">
|
||
Hayal
|
||
</text>
|
||
<text x="95" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||
{fmt(t.hayal)} program
|
||
</text>
|
||
<text x="95" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||
tabanı senden iyi
|
||
</text>
|
||
|
||
{/* aday işareti */}
|
||
<line x1="172" y1="24" x2="172" y2="112" stroke="currentColor" className="text-slate-900" strokeWidth="2" strokeDasharray="4 3" />
|
||
<text x="172" y="16" textAnchor="middle" className="fill-slate-900 text-[12px] font-bold">
|
||
SEN · {fmt(sira)}.
|
||
</text>
|
||
|
||
{/* dengeli */}
|
||
<rect x="174" y="70" width="120" height="32" rx="4" className="fill-amber-100" />
|
||
<text x="234" y="40" textAnchor="middle" className="fill-amber-600 text-[12px] font-semibold">
|
||
Dengeli
|
||
</text>
|
||
<text x="234" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||
{fmt(t.dengeli)} program
|
||
</text>
|
||
<text x="234" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||
{fmt(sira)}. – {fmt(dengeliUst)}. arası taban
|
||
</text>
|
||
|
||
{/* garanti */}
|
||
<rect x="296" y="70" width="324" height="32" rx="4" className="fill-emerald-100" />
|
||
<text x="458" y="40" textAnchor="middle" className="fill-emerald-700 text-[12px] font-semibold">
|
||
Garanti
|
||
</text>
|
||
<text x="458" y="56" textAnchor="middle" className="fill-slate-600 text-[11px] font-medium tabular-nums">
|
||
{fmt(t.garanti)} program
|
||
</text>
|
||
<text x="458" y="123" textAnchor="middle" className="fill-slate-500 text-[10px]">
|
||
{fmt(dengeliUst + 1)}. sonrası — sonu yok, sayfaladıkça uzar
|
||
</text>
|
||
</svg>
|
||
<figcaption className="mt-2 text-xs text-slate-500">
|
||
Liste her dilimde{" "}
|
||
<span className="font-medium text-slate-700">
|
||
sana en yakın tabandan uzağa doğru
|
||
</span>{" "}
|
||
sıralanır; “Daha fazla göster” dedikçe uzaktakiler gelir.
|
||
</figcaption>
|
||
</figure>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Sayfanın kapanış kartı: profili olmayan ziyaretçiyi ana sayfadaki sıralama
|
||
* formuna, sıralamasını zaten girmiş adayı ise doğrudan sonuç sayfasına
|
||
* gönderir — "sıralamanı gir" CTA'sı profil varken gösterilmez.
|
||
*/
|
||
export function DemoKapanisCta() {
|
||
const profil = useTercihProfili();
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-center">
|
||
<p className="font-heading text-base font-bold text-slate-900">
|
||
{profil
|
||
? "Kuralları öğrendin — sayılar zaten senin sıralamanla hesaplandı."
|
||
: "Kuralları öğrendin — şimdi kendi sıralamanla dene."}
|
||
</p>
|
||
<Link
|
||
href={profil ? sonucHref(profil) : "/"}
|
||
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||
>
|
||
{profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"}
|
||
<ArrowRight className="size-4" aria-hidden />
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|