Files
kolaytercih/src/features/pazarlama/components/meraklisina-demo.tsx
bilalgursen 668568b5b5 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>
2026-08-08 14:11:05 +03:00

252 lines
8.8 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";
// /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; &ldquo;Daha fazla göster&rdquo; 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>
);
}