Compare commits

...

7 Commits

Author SHA1 Message Date
bilalgursen
78d7c51a7a perf: deterministik dekor memo'ları ve küçük render süpürmeleri (Faz 7)
Some checks failed
Deploy / deploy (push) Has been cancelled
- pixel-decor: PixelField/PixelDivider piksel üretimi useMemo([seed]) —
  504 iterasyon + ~150 element çekmece reorder'ının her karesinde
  yeniden üretiliyordu
- rapor-listesi: acikSira effect'i önceki-prop desenine döndü (çift render
  ve eslint-disable kalktı), Set lazy init, tekIl useMemo
- program-tablosu: 3 dilimin spread'i useMemo
- typing-animation: displayedText state yerine türetme; kaynak reset'i
  render sırasında — tick başına state yazımı ve effect bağımlılığı azaldı
- site-top-banner: saniyelik geri sayım tiki startTransition'da
- program-liste-verileri: sparkline 6 dizi geçişi tek döngüde
- secimlerim-paneli: SecimSatiri profili prop'tan alır + memo (24 ayrı
  store aboneliği kalktı)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:29:14 +03:00
bilalgursen
f3d8bf9b94 perf: harita/tablo re-render'ları + content-visibility (Faz 6)
- tercih-haritasi: viewBox animasyonu transient — kare başına setState
  (380ms × ~23 render × 81 path) yerine svg.setAttribute; il path ref
  callback'leri key başına stabil; il özetleri/şerit/pin grupları useMemo;
  reduce×3+baskinRisk tek geçiş; çift lookup ve çift uniLogoYoluFromAd kalktı
- liste-store: O(1) idSet + useListedeMi/useListeDoluMu primitive selector'ları;
  program-ekle-butonu artık tüm listeye abone değil — 50 satırlık tabloda
  mutasyon başına 50 yerine yalnız değişen buton render olur
- bolum-program-listesi: decorate-sort-undecorate yerine stable toSorted;
  sıralama/sayfalama startTransition'da
- il-secim-haritasi: normalizeIlAdi modül seviyesinde precompute (render
  başına ~1000 string normalizasyonu vardı); Map/Set/sort useMemo
- kt-cv-satir utility: uzun tablo satırlarında content-visibility:auto +
  contain-intrinsic-size (program/bolum/universite/rapor tabloları)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:23:05 +03:00
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
bilalgursen
90ac590737 perf: sohbet markdown lazy + marked client'tan çıktı, mermaid görünürlükte (Faz 4)
- markdown.tsx: blok bölme marked.lexer yerine çit-farkındalıklı boş satır
  bölücüsü — react-markdown'ın yanında ikinci tam parser (~460 KB paket)
  client'a inmiyordu artık gerekmiyor; memoized blok yapısı aynen korunur
- markdown-lazy: react-markdown+remark zinciri (~215 KB) lazy; fallback
  aynı class'lı düz metin (stream'de flash/CLS yok), sohbet mount'unda
  preloadMarkdown ile chunk önden ısınır — /listem 1315->1133 KB eager JS
- el-cizimi-sema: mermaid importu IntersectionObserver'a bağlandı
  (rootMargin 200px); şemalar hep fold altında, ~1 MB mount'ta inmiyor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:01:48 +03:00
bilalgursen
b7e10efd1d perf: TercihHaritasi dinamik + lazy sarmalayıcılara kapı/preload (Faz 3)
- tercih-haritasi-lazy: turkey-map-react il geometrisi (~197 KB) dynamic
  ssr:false + viewBox oranlı iskelet (CLS yok); /sonuc 1286->1099 KB,
  /listem 1503->1315 KB eager JS
- manuel-harita: liste boşken harita hiç render edilmez (boş haritaya
  197 KB iniyordu) — aynı oranlı placeholder alanı korur
- program tablosu section'ına hover/focus preload (niyet anında ısıtma)
- liste-cekmecesi/kaydet-banner/tercih-profili-kapisi lazy sarmalayıcıları
  kapı kazandı: koşul sağlanana dek chunk inmez; açan butonlara
  (listem-butonu, + butonu, navbar sıralama butonları) hover/focus preload
- liste-cekmecesi: kullanılmayan DrawerDescription importu temizlendi

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:57:48 +03:00
bilalgursen
026debdc06 perf: analytics stratejisi, radix tree-shaking, DevPanel, motion/mini (Faz 2)
- Rybbit Script beforeInteractive -> afterInteractive: analitik artık
  hydration'dan önce kritik yolda çalışmıyor (yalnızca preload hint'i kalıyor)
- next.config: experimental.optimizePackageImports ["radix-ui"] — 36 alt
  paketli kök barrel modül grafiğini şişiriyordu (VPS build OOM geçmişi)
- DevPanel: dev-panel-mount.tsx ile prod'da import() erişilemez dalda;
  dev kodu + dev action proxy'leri prod chunk'lardan çıktı (rota başına ~21 KB)
- motion/mini: use-pixel-heat + sihirbaz-cagri-karti yalnızca duration/ease
  kullanıyor; WAAPI tabanlı mini animate tam motoru bundle'dan çıkarır

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:51:44 +03:00
bilalgursen
53edb2b307 perf: sunucu waterfall'ları ve blocking analitik düzeltildi (Faz 1)
Vercel React Best Practices denetimi, sunucu tarafı:
- odeme: analitik olayı after() ile yanıt sonrasına alındı; ucuz ürün
  doğrulaması oturum turundan öne çekildi; sonuç kartında oturum+sipariş
  paralel okunuyor
- /api/soru POST: gövde+oturum ve 3 bağımsız sorgu Promise.all'a bağlandı
- rehber: prod'da globalThis cache + özetler için frontmatter fast-path
  (24 dosya okuma + marked.parse her render'da tekrarlanıyordu); arama
  kutusuna giden payload RehberAramaKaydi'ya kırpıldı (tarih serileşmiyor)
- /sonuc hattı: ProgramListeSatiri (fakulte/puan2025 client'a serileşmiyor)
- katalog: universite/bolum listelerinde O(n²) filtreler tek geçişe indi
- sira-gerekli + katalog-arama: effect'te setState kalktı (lint yeşil)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:48:27 +03:00
53 changed files with 1086 additions and 421 deletions

View File

@@ -22,12 +22,13 @@ const nextConfig: NextConfig = {
"openai",
"resend",
],
...(kaynakKisitli
? {
// cacheComponents açıkken prerender fazında source map üretimi varsayılan
// açık; 1900+ statik sayfada ciddi bellek maliyeti var.
enablePrerenderSourceMaps: false,
experimental: {
experimental: {
// radix-ui kök barrel'ı 36 alt paketi çeker ve Next'in varsayılan
// optimize listesinde yoktur; named import'lar doğrudan alt modüle
// çevrilir (dev boot / build modül grafiği küçülür).
optimizePackageImports: ["radix-ui"],
...(kaynakKisitli
? {
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
// hedefi byte cinsinden ayrıca verilir (1.5 GB).
turbopackMemoryLimit: 1_610_612_736,
@@ -35,7 +36,14 @@ const nextConfig: NextConfig = {
// worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir.
cpus: 1,
staticGenerationMaxConcurrency: 4,
},
}
: {}),
},
...(kaynakKisitli
? {
// cacheComponents açıkken prerender fazında source map üretimi varsayılan
// açık; 1900+ statik sayfada ciddi bellek maliyeti var.
enablePrerenderSourceMaps: false,
}
: {}),
async redirects() {

View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { dilimAra } from "@/lib/db";
import { programOzetle } from "@/features/liste/liste-queries";
import {
PUAN_TURLERI,
type DilimKey,
@@ -39,5 +40,6 @@ export function GET(request: NextRequest) {
limit: SAYFA_BOYU,
offset,
});
return NextResponse.json({ programlar, toplam });
// Tablo satırının kullanmadığı alanlar payload'a girmesin
return NextResponse.json({ programlar: programlar.map(programOzetle), toplam });
}

View File

@@ -106,17 +106,19 @@ export async function GET() {
}
export async function POST(request: NextRequest) {
const session = await getSession();
// Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı
// kalır (önce 401, sonra 400).
const [session, hamBody] = await Promise.all([
getSession(),
request.json().catch(() => null),
]);
if (!session) {
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
}
let body: { message?: unknown; clientMessageId?: unknown };
try {
body = await request.json();
} catch {
if (hamBody === null || typeof hamBody !== "object") {
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
}
const body = hamBody as { message?: unknown; clientMessageId?: unknown };
const mesaj = typeof body.message === "string" ? body.message.trim() : "";
const clientMessageId =
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
@@ -124,24 +126,30 @@ export async function POST(request: NextRequest) {
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
}
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
const rapor = await kullanicininRaporu(session.user.id);
// Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel.
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
const [rapor, gecmisHam, kullanici] = await Promise.all([
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
kullanicininRaporu(session.user.id),
appDb
.select()
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(desc(schema.chatMessages.createdAt))
.limit(MAX_GECMIS),
appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
}),
]);
if (!rapor?.result) {
return Response.json(
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
{ status: 409 },
);
}
// Geçmişi krediyi düşmeden ÖNCE oku ki kullanıcı mesajı dahil olmasın
const gecmis = (
await appDb
.select()
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(desc(schema.chatMessages.createdAt))
.limit(MAX_GECMIS)
).reverse();
const gecmis = gecmisHam.reverse();
// Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = await spendCreditForMessage({
@@ -166,10 +174,6 @@ export async function POST(request: NextRequest) {
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
// halde danışman kilitli listeyi sözle sızdırabilir.
const kullanici = await appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { hasPaket: true },
});
const kilitli = !kullanici?.hasPaket;
const tamOzet = listeOzetiCikar(
rapor.result as RaporSonuc,

View File

@@ -194,6 +194,15 @@
}
}
/* Uzun tablo satırları: viewport dışı satırların layout/paint'i atlanır
(rendering-content-visibility). contain-intrinsic-size satır yüksekliği
tahminidir — scrollbar zıplamasını önler. Sorun çıkarsa satır bazında
class kaldırılarak geri alınabilir. */
@utility kt-cv-satir {
content-visibility: auto;
contain-intrinsic-size: auto 57px;
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@@ -10,7 +10,7 @@ import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel";
import { DevPanelMount } from "@/components/dev/dev-panel-mount";
import { ScrollFlow } from "@/components/scroll-flow";
import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo";
import "./globals.css";
@@ -85,19 +85,6 @@ export default function RootLayout({
// içerikle büyümeli, viewport tabanı body'deki min-h-dvh'den gelir.
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
>
{/* Rybbit analitik — beforeInteractive her zaman <head> içine defer olarak
enjekte edilir. Yalnızca prod build'de: Rybbit localhost'u da takip
ettiği için dev trafiği prod verisini kirletmesin (DevPanel ile aynı
kapı). mask-patterns: /sonuc URL'i ham başarı sıralamasını,
/odeme/sonuc sipariş kimliğini taşıyor — path tek satırda toplanır. */}
{process.env.NODE_ENV === "production" ? (
<Script
src="https://rybbit.kolaytercih.com/api/script.js"
data-site-id="f2cb042c657e"
data-mask-patterns='["/sonuc", "/odeme/sonuc"]'
strategy="beforeInteractive"
/>
) : null}
<body className="min-h-dvh flex flex-col bg-slate-50">
<HeaderGate>
{/* Site geneli progressive blur — üst ve alt */}
@@ -130,10 +117,23 @@ export default function RootLayout({
{/* İlk "+" için sıra + sihirbaz kapısı */}
<TercihProfiliKapisiLazy />
<Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
{process.env.NODE_ENV !== "production" ? <DevPanelMount /> : null}
<JsonLd data={organizationJsonLd()} />
<JsonLd data={webSiteJsonLd()} />
{/* Rybbit analitik — afterInteractive: hydration sonrası client'ta
enjekte edilir, LCP/kritik yolla yarışmaz. Yalnızca prod build'de:
Rybbit localhost'u da takip ettiği için dev trafiği prod verisini
kirletmesin (DevPanel ile aynı kapı). mask-patterns: /sonuc URL'i
ham başarı sıralamasını, /odeme/sonuc sipariş kimliğini taşıyor. */}
{process.env.NODE_ENV === "production" ? (
<Script
src="https://rybbit.kolaytercih.com/api/script.js"
data-site-id="f2cb042c657e"
data-mask-patterns='["/sonuc", "/odeme/sonuc"]'
strategy="afterInteractive"
/>
) : null}
</body>
</html>
);

View File

@@ -0,0 +1,18 @@
"use client";
import dynamic from "next/dynamic";
// NODE_ENV build sırasında statik yerine konur: prod build'de import()
// erişilemez dalda kalır ve dev-panel (+ dev server-action proxy'leri)
// modül grafiğine hiç girmez — layout'taki statik import ~92 KB'lık
// paylaşılan chunk'a dev kodu taşıyordu. Dev'de panel dynamic yüklenir.
const DevPanelIc =
process.env.NODE_ENV !== "production"
? dynamic(() => import("./dev-panel").then((m) => m.DevPanel), {
ssr: false,
})
: null;
export function DevPanelMount() {
return DevPanelIc ? <DevPanelIc /> : null;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, type ReactNode } from "react";
import { useMemo, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { usePixelHeat } from "@/components/use-pixel-heat";
@@ -88,35 +88,41 @@ export function PixelField({
const COLS = 42;
const ROWS = 12;
const cx = (COLS - 1) / 2;
const cy = (ROWS - 1) / 2;
const maxD = Math.sqrt(cx * cx + cy * cy);
const pixels: ReactNode[] = [];
let accentIdx = 0;
// Çıktı seed'e göre deterministik; 504 iterasyon + ~150 element üretimini
// her render'da (ör. çekmece reorder'ında) tekrarlama (rendering-hoist-jsx)
const pixels = useMemo(() => {
const cx = (COLS - 1) / 2;
const cy = (ROWS - 1) / 2;
const maxD = Math.sqrt(cx * cx + cy * cy);
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
// (genişliğin %22–78 bandı) tüm yükseklik boyunca tamamen boş kalır
const BAND_MIN = COLS * 0.22;
const BAND_MAX = COLS * 0.78;
const sonuc: ReactNode[] = [];
let accentIdx = 0;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (x > BAND_MIN && x < BAND_MAX) continue;
// Kenarlara doğru artan doluluk
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
const density = 0.1 + 0.42 * d * d;
if (hash(x, y, seed) > density) continue;
const accent = hash(x, y, seed + 99) > 0.96;
pixels.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0,
}),
);
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
// (genişliğin %22–78 bandı) tüm yükseklik boyunca tamamen boş kalır
const BAND_MIN = COLS * 0.22;
const BAND_MAX = COLS * 0.78;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (x > BAND_MIN && x < BAND_MAX) continue;
// Kenarlara doğru artan doluluk
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
const density = 0.1 + 0.42 * d * d;
if (hash(x, y, seed) > density) continue;
const accent = hash(x, y, seed + 99) > 0.96;
sonuc.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0,
}),
);
}
}
}
return sonuc;
}, [seed]);
return (
<svg
@@ -149,28 +155,32 @@ export function PixelDivider({
const COLS = 12;
const ROWS = 2;
const cx = (COLS - 1) / 2;
const pixels: ReactNode[] = [];
let accentPlaced = false;
// Deterministik çıktı — render başına yeniden üretme (PixelField ile aynı)
const pixels = useMemo(() => {
const cx = (COLS - 1) / 2;
const sonuc: ReactNode[] = [];
let accentPlaced = false;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
const dx = Math.abs(x - cx) / cx;
const rowFalloff = y === 0 ? 0.9 : 0.35;
const density = (1 - dx) ** 1.1 * rowFalloff;
if (hash(x, y, seed) > density) continue;
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
if (accent) accentPlaced = true;
pixels.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
}),
);
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
const dx = Math.abs(x - cx) / cx;
const rowFalloff = y === 0 ? 0.9 : 0.35;
const density = (1 - dx) ** 1.1 * rowFalloff;
if (hash(x, y, seed) > density) continue;
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
if (accent) accentPlaced = true;
sonuc.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
}),
);
}
}
}
return sonuc;
}, [seed]);
return (
<svg

View File

@@ -26,22 +26,32 @@ export function programEtkinSira(program: SiraSerisi): number | null {
/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
const yillar = [
// Satır başına render edilen bileşen: filter/map/min/max/map/map'in 6 ayrı
// geçişi tek döngüde toplanır (js-combine-iterations, js-min-max-loop)
const yillar: [number, number][] = [];
let min = Infinity;
let max = -Infinity;
const ozetParcalari: string[] = [];
for (const [yil, sira] of [
[2021, program.sira2021],
[2022, program.sira2022],
[2023, program.sira2023],
[2024, program.sira2024],
[2025, program.sira2025],
].filter((yil): yil is [number, number] => yil[1] != null);
] as const) {
if (sira == null) continue;
yillar.push([yil, sira]);
if (sira < min) min = sira;
if (sira > max) max = sira;
ozetParcalari.push(`${yil}: ~${sira.toLocaleString("tr-TR")}.`);
}
if (yillar.length < 2) return <span className="text-slate-400">—</span>;
const siralar = yillar.map(([, sira]) => sira);
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const genislik = 56;
const yukseklik = 18;
const bosluk = 2;
// min/max'a bağımlı olduğu için ikinci kısa döngü
const noktalar = yillar.map(([yil, sira], index) => {
const x =
bosluk +
@@ -54,9 +64,7 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
return { x, y, yil, sira };
});
const son = noktalar[noktalar.length - 1];
const ozet = yillar
.map(([yil, sira]) => `${yil}: ~${sira.toLocaleString("tr-TR")}.`)
.join("\n");
const ozet = ozetParcalari.join("\n");
return (
<svg

View File

@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { KatalogArama } from "@/features/katalog/components/katalog-arama";
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
import { getAllRehberler } from "@/lib/rehber";
import { getRehberAramaKayitlari } from "@/lib/rehber";
export function SiteHeader() {
return (
@@ -32,7 +32,7 @@ export function SiteHeader() {
</span>
</span>
</Link>
<KatalogArama rehberler={getAllRehberler()} />
<KatalogArama rehberler={getRehberAramaKayitlari()} />
{/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */}
<Suspense fallback={<UserNavFallback />}>
<UserNav />

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { startTransition, useEffect, useState } from "react";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { CalendarDays, Clock3 } from "lucide-react";
@@ -47,7 +47,9 @@ export function SiteTopBanner() {
const [geriSayim, setGeriSayim] = useState<GeriSayim | null>(null);
useEffect(() => {
const guncelle = () => setGeriSayim(geriSayimiHesapla(Date.now()));
// Saniyelik tik acil değil — layout'ta her sayfada monte; urgent şeritten çık
const guncelle = () =>
startTransition(() => setGeriSayim(geriSayimiHesapla(Date.now())));
guncelle();
const zamanlayici = window.setInterval(guncelle, 1_000);

View File

@@ -0,0 +1,32 @@
"use client";
// react-markdown + remark zinciri (~215 KB) yalnızca markdown gerçekten
// çizileceği zaman insin. next/dynamic yerine elle sarmalayıcı: `loading`
// children alamıyor, fallback'in AYNI class'lı düz metin olması gerekiyor ki
// stream sırasında iskelet/boşluk flash'ı olmasın (chunk gelince yerinde
// zenginleşir).
import { useEffect, useReducer } from "react";
import type { MarkdownProps } from "./markdown";
let mod: typeof import("./markdown") | null = null;
let soz: Promise<void> | null = null;
/** Chunk'ı önden ısıt (ör. sohbet mount olduğunda). */
export function preloadMarkdown(): Promise<void> {
return (soz ??= import("./markdown").then((m) => {
mod = m;
}));
}
export function MarkdownLazy({
fallback,
...props
}: MarkdownProps & { fallback: React.ReactNode }) {
const [, tazele] = useReducer((c: number) => c + 1, 0);
useEffect(() => {
if (!mod) void preloadMarkdown().then(() => tazele());
}, []);
const Markdown = mod?.Markdown;
return Markdown ? <Markdown {...props} /> : <>{fallback}</>;
}

View File

@@ -1,5 +1,4 @@
import { cn } from "@/lib/utils"
import { marked } from "marked"
import { memo, useId, useMemo } from "react"
import ReactMarkdown, { Components } from "react-markdown"
import remarkBreaks from "remark-breaks"
@@ -13,9 +12,27 @@ export type MarkdownProps = {
components?: Partial<Components>
}
// Stream memoization'ı için bloklara bölme. Eskiden marked.lexer yapıyordu —
// react-markdown'ın micromark'ına ek ikinci bir tam parser'ı (~460 KB paket)
// client'a taşımak blok bölmek için gereksiz: kod çiti dışındaki boş satırlar
// zaten markdown'da blok sınırıdır. Çit içi boş satırlar korunur.
function parseMarkdownIntoBlocks(markdown: string): string[] {
const tokens = marked.lexer(markdown)
return tokens.map((token) => token.raw)
const bloklar: string[] = []
let mevcut: string[] = []
let citte = false
for (const satir of markdown.split("\n")) {
if (/^\s*(```|~~~)/.test(satir)) citte = !citte
if (!citte && satir.trim() === "") {
if (mevcut.length) {
bloklar.push(mevcut.join("\n"))
mevcut = []
}
} else {
mevcut.push(satir)
}
}
if (mevcut.length) bloklar.push(mevcut.join("\n"))
return bloklar.length ? bloklar : [markdown]
}
function extractLanguage(className?: string): string {

View File

@@ -6,7 +6,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { Markdown } from "./markdown"
import { MarkdownLazy } from "./markdown-lazy"
import type { MarkdownProps } from "./markdown"
export type MessageProps = {
children: React.ReactNode
@@ -48,7 +49,7 @@ export type MessageContentProps = {
children: React.ReactNode
markdown?: boolean
className?: string
} & React.ComponentProps<typeof Markdown> &
} & MarkdownProps &
React.HTMLProps<HTMLDivElement>
const MessageContent = ({
@@ -63,9 +64,15 @@ const MessageContent = ({
)
return markdown ? (
<Markdown className={classNames} {...props}>
// Fallback aynı class'lı düz metin: chunk inene dek CLS/flash olmadan
// ham içerik görünür, sonra yerinde zenginleşir.
<MarkdownLazy
className={classNames}
fallback={<div className={classNames}>{children}</div>}
{...props}
>
{children as string}
</Markdown>
</MarkdownLazy>
) : (
<div className={classNames} {...props}>
{children}

View File

@@ -81,7 +81,6 @@ export function TypingAnimation({
Component
] as TypingAnimationMotionComponent
const [displayedText, setDisplayedText] = useState<string>("")
const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
@@ -109,21 +108,32 @@ export function TypingAnimation({
[words, children]
)
// Kaynak metin değişince animasyon baştan başlar — bilinçli reset
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setDisplayedText("")
// Kaynak metin değişince animasyon baştan başlar — önceki-prop deseniyle
// render sırasında (effect'te setState çift render yaratıyordu)
const [oncekiKaynak, setOncekiKaynak] = useState(animationSourceKey)
if (animationSourceKey !== oncekiKaynak) {
setOncekiKaynak(animationSourceKey)
setCurrentWordIndex(0)
setCurrentCharIndex(0)
setPhase("typing")
}, [animationSourceKey])
}
// Görünen metin state değil türetme: tick başına ekstra state yazımı ve
// grapheme dizisi allocation'ı kalkar (rerender-derived-state-no-effect)
const displayedText = useMemo(
() =>
Array.from(wordsToAnimate[currentWordIndex] || "")
.slice(0, currentCharIndex)
.join(""),
[wordsToAnimate, currentWordIndex, currentCharIndex]
)
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (!azMotion && shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay =
delay > 0 && displayedText === ""
delay > 0 && currentCharIndex === 0
? delay
: phase === "typing"
? typingSpeed
@@ -138,9 +148,6 @@ export function TypingAnimation({
switch (phase) {
case "typing":
if (currentCharIndex < graphemes.length) {
setDisplayedText(
graphemes.slice(0, currentCharIndex + 1).join("")
)
setCurrentCharIndex(currentCharIndex + 1)
} else {
if (hasMultipleWords || loop) {
@@ -159,9 +166,6 @@ export function TypingAnimation({
case "deleting":
if (currentCharIndex > 0) {
setDisplayedText(
graphemes.slice(0, currentCharIndex - 1).join("")
)
setCurrentCharIndex(currentCharIndex - 1)
} else {
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
@@ -184,7 +188,6 @@ export function TypingAnimation({
phase,
currentCharIndex,
currentWordIndex,
displayedText,
wordsToAnimate,
hasMultipleWords,
loop,

View File

@@ -16,7 +16,11 @@
*/
import { useEffect, type RefObject } from "react";
import { animate, type AnimationPlaybackControls } from "motion";
// WAAPI tabanlı mini animate yeterli (yalnızca duration/ease kullanılıyor);
// tam motor client bundle'ına girmez. Tip mini'den export edilmiyor —
// type-only import build'de silinir, maliyeti yok.
import { animate } from "motion/mini";
import type { AnimationPlaybackControls } from "motion";
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
const HOT_COLOR = "#f97316"; // orange-500

View File

@@ -34,15 +34,26 @@ function KategoriGruplari({
bolumler: BolumOzet[];
seviye: "lisans" | "onlisans";
}) {
const kalanlar = new Set(bolumler.map((b) => b.slug));
const gruplar = KATEGORILER.map((kategori) => {
const uyeler = bolumler.filter((b) =>
(b.kategoriler as readonly string[]).includes(kategori),
);
for (const b of uyeler) kalanlar.delete(b.slug);
return { kategori: kategori as string, uyeler };
}).filter((g) => g.uyeler.length > 0);
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
// Tek geçişte kategori index'i — kategori×bölüm çapraz filter yerine.
// Bir bölüm birden çok kategoride görünebilir; hiçbirinde yoksa "Diğer"e düşer.
const uyeMap = new Map<string, BolumOzet[]>();
const diger: BolumOzet[] = [];
const gecerli = new Set<string>(KATEGORILER);
for (const b of bolumler) {
let eslesti = false;
for (const kategori of b.kategoriler as readonly string[]) {
if (!gecerli.has(kategori)) continue;
const grup = uyeMap.get(kategori);
if (grup) grup.push(b);
else uyeMap.set(kategori, [b]);
eslesti = true;
}
if (!eslesti) diger.push(b);
}
const gruplar = KATEGORILER.filter((k) => uyeMap.has(k)).map((kategori) => ({
kategori: kategori as string,
uyeler: uyeMap.get(kategori)!,
}));
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
return (

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { startTransition, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/types/yokatlas";
@@ -104,19 +104,17 @@ export function BolumProgramListesi({
const siraliProgramlar = useMemo(() => {
if (!aktifAlan) return programlar;
return programlar
.map((program, index) => ({ program, index }))
.sort((a, b) => {
const aDeger = a.program[aktifAlan];
const bDeger = b.program[aktifAlan];
if (aDeger == null && bDeger == null) return a.index - b.index;
if (aDeger == null) return 1;
if (bDeger == null) return -1;
const fark = aDeger - bDeger;
return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark;
})
.map(({ program }) => program);
// toSorted stable: eşitlikte orijinal sıra zaten korunur, index
// dekorasyonu (N obje allocation + 2 ek geçiş) gereksizdi.
return programlar.toSorted((a, b) => {
const aDeger = a[aktifAlan];
const bDeger = b[aktifAlan];
if (aDeger == null && bDeger == null) return 0;
if (aDeger == null) return 1;
if (bDeger == null) return -1;
const fark = aDeger - bDeger;
return yon === "artan" ? fark : -fark;
});
}, [aktifAlan, programlar, yon]);
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
@@ -129,18 +127,22 @@ export function BolumProgramListesi({
[siraliProgramlar, gosterilenSayfa],
);
// Yüzlerce satırın sort+render'ı tıklamayı bloklamasın; buton geri
// bildirimi CSS'ten geldiği için tüm güncelleme non-urgent kalabilir.
function sirala(alan: SiralamaAlani) {
setIstemciSayfa(1);
if (aktifAlan === alan) {
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
return;
}
setAktifAlan(alan);
setYon("artan");
startTransition(() => {
setIstemciSayfa(1);
if (aktifAlan === alan) {
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
return;
}
setAktifAlan(alan);
setYon("artan");
});
}
function istemciSayfaDegistir(n: number) {
setIstemciSayfa(n);
startTransition(() => setIstemciSayfa(n));
listeRef.current?.scrollIntoView({ block: "start" });
}
@@ -193,7 +195,7 @@ export function BolumProgramListesi({
const devlet = p.unitur === "DEVLET";
return (
<TableRow key={p.id}>
<TableRow key={p.id} className="kt-cv-satir">
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p.universiteSlug ? (

View File

@@ -1,18 +1,29 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 type { RehberOzet } from "@/lib/rehber";
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 {
@@ -60,6 +71,18 @@ 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",
@@ -81,7 +104,7 @@ const KESFET_TONLARI = {
},
} as const;
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
const router = useRouter();
const profil = useTercihProfili();
const [acik, setAcik] = useState(false);
@@ -94,8 +117,13 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
}
const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
const [yukleniyor, setYukleniyor] = useState(false);
const [macMi, setMacMi] = useState(true);
// 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 });
@@ -106,6 +134,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
} | null>(null);
const temizSorgu = sorgu.trim();
const aramaAktif = temizSorgu.length >= 2;
const yukleniyor = aramaAktif && cevaplananSorgu !== temizSorgu;
const kenarBlurGuncelle = useCallback(() => {
const alan = kaydirmaRef.current;
@@ -122,16 +151,16 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
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 =
@@ -139,40 +168,39 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
sonuclar.universiteler.length > 0 ||
rehberSonuclari.length > 0;
useEffect(() => {
const platform =
(navigator as { userAgentData?: { platform?: string } }).userAgentData
?.platform ??
navigator.platform ??
"";
setMacMi(/mac|iphone|ipad/i.test(platform));
}, []);
// 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<AramaSonuclari>(
`/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 = {
@@ -181,10 +209,11 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
};
} 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);
@@ -217,7 +246,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
if (yeniAcik) {
setSorgu("");
setSonuclar(BOS_SONUCLAR);
setYukleniyor(false);
setCevaplananSorgu("");
setKenarBlur({ ust: false, alt: false });
}
}
@@ -243,6 +272,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<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"
>
@@ -269,6 +300,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<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 />
@@ -304,6 +337,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<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"
>
@@ -329,6 +364,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<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"
>
@@ -375,12 +412,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
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"
@@ -722,7 +754,7 @@ function RehberSonucKarti({
yazi,
onSec,
}: {
yazi: RehberOzet;
yazi: RehberAramaKaydi;
onSec: () => void;
}) {
return (

View File

@@ -36,13 +36,15 @@ function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
export function UniversiteKatalogu() {
const hepsi = getAllUniversiteler();
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
const vakif = hepsi.filter(
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
);
const digerUniler = hepsi.filter(
(u) => !devlet.includes(u) && !vakif.includes(u),
);
// Tek geçişte üç grup — filter×3 + includes'lı O(n²) tarama yerine
const devlet: UniOzet[] = [];
const vakif: UniOzet[] = [];
const digerUniler: UniOzet[] = [];
for (const u of hepsi) {
if (u.unitur === "DEVLET") devlet.push(u);
else if (u.unitur === "VAKIF" || u.unitur === "VAKIF MYO") vakif.push(u);
else digerUniler.push(u);
}
return (
<>

View File

@@ -66,7 +66,7 @@ export function UniversiteProgramTablosu({
{programlar.map((p) => {
const taban = programEtkinSira(p);
return (
<TableRow key={p.id}>
<TableRow key={p.id} className="kt-cv-satir">
<TableCell className="max-w-0 w-full">
{p.bolumSlug ? (
<Link

View File

@@ -2,10 +2,22 @@
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline
// bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni).
// Kapı: liste eşiğin altındayken chunk hiç inmez. Giriş/pathname koşulları
// bileşenin kendisinde kalır — burada yalnızca en ayırt edici koşul var.
import dynamic from "next/dynamic";
import { useManuelListe } from "../liste-store";
export const KaydetBannerLazy = dynamic(
// kaydet-banner.tsx'teki ESIK ile aynı değer; oradan import etmek statik
// bağımlılıkla chunk'ı geri çekerdi.
const ESIK = 3;
const KaydetBannerIc = dynamic(
() => import("./kaydet-banner").then((m) => m.KaydetBanner),
{ ssr: false },
);
export function KaydetBannerLazy() {
const liste = useManuelListe();
return liste.length >= ESIK ? <KaydetBannerIc /> : null;
}

View File

@@ -2,11 +2,27 @@
// Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline
// bundle'a girmemesi için ssr'siz dynamic import (layout server component
// olduğundan bu sarmalayıcı gerekli).
// olduğundan bu sarmalayıcı gerekli). Kapı: liste boş ve çekmece kapalıyken
// chunk hiç inmez — hydration'la birlikte koşulsuz inmesin (bundle-conditional).
import dynamic from "next/dynamic";
import { useCekmeceAcik, useManuelListe } from "../liste-store";
export const ListeCekmecesiLazy = dynamic(
const ListeCekmecesiIc = dynamic(
() => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi),
{ ssr: false },
);
/** Navbar butonu hover/focus'unda chunk'ı önden ısıt (bundle-preload). */
export function preloadListeCekmecesi() {
void import("./liste-cekmecesi");
}
export function ListeCekmecesiLazy() {
const acik = useCekmeceAcik();
const liste = useManuelListe();
// "+" uçuş animasyon katmanı da çekmece modülünde yaşadığı için liste
// doluysa (çekmece kapalı olsa da) mount kalır; yalnızca boş+kapalı durumda
// atlanır — ilk ziyaretin en yaygın hâli.
return acik || liste.length > 0 ? <ListeCekmecesiIc /> : null;
}

View File

@@ -21,7 +21,6 @@ import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,

View File

@@ -12,6 +12,7 @@ import {
useCekmeceAcik,
useManuelListe,
} from "../liste-store";
import { preloadListeCekmecesi } from "./liste-cekmecesi-lazy";
export function ListemButonu() {
const liste = useManuelListe();
@@ -26,6 +27,8 @@ export function ListemButonu() {
<button
type="button"
onClick={cekmeceDegistir}
onPointerEnter={preloadListeCekmecesi}
onFocus={preloadListeCekmecesi}
aria-expanded={acik}
aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`}
title="Tablodan seçtiğin programlar"

View File

@@ -5,7 +5,8 @@
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
import { useMemo, useState } from "react";
import { TercihHaritasi, type HaritaPin } from "@/features/liste/components/tercih-haritasi";
import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
import type { HaritaPin } from "@/features/liste/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "../liste-store";
@@ -53,9 +54,24 @@ export function ManuelHarita() {
return { pinler: [...gruplar.values()], haritaDisi };
}, [liste]);
// Liste boşken 197 KB'lık il geometrisi hiç inmesin: aynı oranlı
// placeholder alanı rezerve eder, ilk eklemede lazy harita yerine oturur.
if (liste.length === 0) {
return (
<div>
<div className="flex aspect-[1050/447] w-full items-center justify-center rounded-xl bg-slate-100/60">
<p className="max-w-xs px-4 text-center text-xs text-slate-500">
Tablodan <span aria-hidden>+</span> ile ekledikçe üniversiteler
haritada belirir.
</p>
</div>
</div>
);
}
return (
<div>
<TercihHaritasi
<TercihHaritasiLazy
pinler={pinler}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
@@ -65,12 +81,6 @@ export function ManuelHarita() {
{haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
{liste.length === 0 ? (
<p className="mt-2 text-center text-xs text-slate-500">
Tablodan <span aria-hidden>+</span> ile ekledikçe üniversiteler
haritada belirir.
</p>
) : null}
</div>
);
}

View File

@@ -5,12 +5,13 @@ import { toast } from "sonner";
import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {
listedeMi,
MANUEL_LISTE_MAX,
useManuelListe,
useListeDoluMu,
useListedeMi,
type ManuelKaynak,
} from "../liste-store";
import { programEkleIste } from "../hooks/use-tercih-profili";
import { preloadTercihProfiliKapisi } from "./tercih-profili-kapisi-lazy";
type ProgramOzet = Pick<
Program,
@@ -37,9 +38,11 @@ export function ProgramEkleButonu({
adayTur?: string;
className?: string;
}) {
const liste = useManuelListe();
const listede = liste.some((t) => t.id === program.id) || listedeMi(program.id);
const doldu = liste.length >= MANUEL_LISTE_MAX;
// Primitive snapshot'lı selector'lar: liste mutasyonunda yalnız üyeliği
// değişen buton render olur (eskiden 50 satır × tam liste aboneliği + aynı
// taramanın listedeMi ile ikinci kez yapılması vardı).
const listede = useListedeMi(program.id);
const doldu = useListeDoluMu();
function handleClick(e: React.MouseEvent) {
e.preventDefault();
@@ -59,6 +62,9 @@ export function ProgramEkleButonu({
type="button"
disabled={listede || doldu}
onClick={handleClick}
// Profilsiz kullanıcıda + kapıyı açar — chunk'ı niyet anında ısıt
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
title={
listede
? "Zaten seçtiklerinde"

View File

@@ -5,7 +5,7 @@
// sayfalanır ("daha fazla göster" /api/programlar'dan akıtır). Her satırın "+"
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
import { useState } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
ChevronDown,
@@ -13,7 +13,12 @@ import {
Scale,
ShieldCheck,
} from "lucide-react";
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/types/yokatlas";
import type {
DilimKey,
ProgramListeSatiri,
PuanTuruKey,
RankResultsListe,
} from "@/types/yokatlas";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import {
@@ -31,6 +36,7 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
import { preloadTercihHaritasi } from "@/features/liste/components/tercih-haritasi-lazy";
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
import { UniLogo } from "@/components/uni-logo";
@@ -70,7 +76,7 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
riskli: "bg-red-500",
};
const BOS_EKSTRA: Record<DilimKey, Program[]> = {
const BOS_EKSTRA: Record<DilimKey, ProgramListeSatiri[]> = {
hayal: [],
dengeli: [],
garanti: [],
@@ -78,7 +84,7 @@ const BOS_EKSTRA: Record<DilimKey, Program[]> = {
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
function varsayilanDilim(sonuclar: RankResults): DilimKey {
function varsayilanDilim(sonuclar: RankResultsListe): DilimKey {
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
? "garanti"
: "dengeli";
@@ -89,7 +95,7 @@ export function ProgramTablosu({
adaySira,
tur,
}: {
sonuclar: RankResults;
sonuclar: RankResultsListe;
adaySira: number;
tur: PuanTuruKey;
}) {
@@ -113,6 +119,13 @@ export function ProgramTablosu({
}
}
// Görünmeyen sekmeler dahil 3 dilimin spread'i her render'da tekrarlanmasın
const tumSatirlar = useMemo(() => {
const r = {} as Record<DilimKey, ProgramListeSatiri[]>;
for (const d of DILIMLER) r[d.key] = [...sonuclar[d.key], ...ekstra[d.key]];
return r;
}, [sonuclar, ekstra]);
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim);
try {
@@ -120,7 +133,7 @@ export function ProgramTablosu({
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
);
if (!res.ok) return;
const veri = (await res.json()) as { programlar: Program[] };
const veri = (await res.json()) as { programlar: ProgramListeSatiri[] };
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
setEkstra((mevcut) => {
const eldekiler = new Set(
@@ -141,7 +154,14 @@ export function ProgramTablosu({
}
return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
<section
className="mt-16"
aria-label="Sıralamanla açılan programlar"
// Tabloya niyet gösteren kullanıcı büyük ihtimalle + ile harita
// çizdirecek — chunk'ı görünmeden ısıt (bundle-preload)
onPointerEnter={preloadTercihHaritasi}
onFocusCapture={preloadTercihHaritasi}
>
<Tabs
value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)}
@@ -161,7 +181,7 @@ export function ProgramTablosu({
</TabsList>
{DILIMLER.map((d) => {
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]];
const satirlar = tumSatirlar[d.key];
const toplam = sonuclar.toplam[d.key];
return (
<TabsContent key={d.key} value={d.key}>
@@ -232,7 +252,7 @@ function ProgramSatiri({
adaySira,
tur,
}: {
program: Program;
program: ProgramListeSatiri;
adaySira: number;
tur: PuanTuruKey;
}) {
@@ -241,7 +261,7 @@ function ProgramSatiri({
const devlet = p.unitur === "DEVLET";
return (
<TableRow>
<TableRow className="kt-cv-satir">
<TableCell className="pl-4 pr-0">
{risk ? (
<span

View File

@@ -1,5 +1,6 @@
"use client";
import { memo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { UniLogo } from "@/components/uni-logo";
@@ -29,6 +30,7 @@ import {
sonucaGitIste,
useTercihProfili,
} from "../hooks/use-tercih-profili";
import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
@@ -176,7 +178,7 @@ export function SecimlerimPaneli() {
<ol className="flex flex-col gap-3">
{liste.map((t, i) => (
<SecimSatiri key={t.id} tercih={t} sira={i + 1} />
<SecimSatiri key={t.id} tercih={t} sira={i + 1} profil={profil} />
))}
</ol>
</section>
@@ -196,14 +198,17 @@ function Istatistik({ etiket, deger }: { etiket: string; deger: number }) {
);
}
function SecimSatiri({
// Profil parent'tan prop olarak gelir (parent zaten abone) + memo: 24 satırın
// her biri ayrı store aboneliği kurup her profil olayında uyanıyordu.
const SecimSatiri = memo(function SecimSatiri({
tercih,
sira,
profil,
}: {
tercih: ManuelTercih;
sira: number;
profil: TercihProfili | null;
}) {
const profil = useTercihProfili();
const degerlendirme = profil
? tercihDegerlendir(
{
@@ -302,4 +307,4 @@ function SecimSatiri({
</div>
</li>
);
}
});

View File

@@ -0,0 +1,27 @@
"use client";
// turkey-map-react'ın il geometrisi (~197 KB) yalnızca harita gerçekten
// çizileceği zaman insin: dynamic + ssr'siz. İskelet, haritanın gerçek
// oranını (viewBox 1050 × 447, bkz. tercih-haritasi TAM_GORUNUM) rezerve
// eder — chunk gelince yerleşim zıplamaz.
import dynamic from "next/dynamic";
export function HaritaIskeleti() {
return (
<div
aria-hidden
className="aspect-[1050/447] w-full animate-pulse rounded-xl bg-slate-100"
/>
);
}
export const TercihHaritasiLazy = dynamic(
() => import("./tercih-haritasi").then((m) => m.TercihHaritasi),
{ ssr: false, loading: () => <HaritaIskeleti /> },
);
/** Hover/focus gibi niyet sinyallerinde chunk'ı önden ısıt. */
export function preloadTercihHaritasi() {
void import("./tercih-haritasi");
}

View File

@@ -57,6 +57,12 @@ const TAM_GORUNUM = {
type ViewBox = typeof TAM_GORUNUM;
// normalizeIlAdi il başına modül yükünde bir kez — render başına 81 çağrı yerine
const NORMAL_SEHIRLER = cities.map((city) => ({
city,
key: normalizeIlAdi(city.name),
}));
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
function kisaUniAdi(ad: string): string {
const kisa = ad
@@ -122,18 +128,54 @@ export function TercihHaritasi({
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
onTercihSec?: (sira: number) => void;
}) {
const pathRefs = useRef(new Map<string, SVGPathElement>());
// 81 il path'inin inline ref callback'i her render'da yeniden üretilmesin
// (React her render'da 81 detach/attach yapıyordu): key başına stabil
// callback bir kez kurulur (il listesi statik). Ref yerine lazy-state
// closure'ı — callback'ler commit anında koşar, render'da ref okunmaz.
const [pathKayit] = useState(() => {
const elemanlar = new Map<string, SVGPathElement>();
const callbacks = new Map<string, (el: SVGPathElement | null) => void>();
for (const { key } of NORMAL_SEHIRLER) {
callbacks.set(key, (el) => {
if (el) elemanlar.set(key, el);
else elemanlar.delete(key);
});
}
return { elemanlar, callbacks };
});
const svgRef = useRef<SVGSVGElement | null>(null);
// vb yalnızca sf (zoom oran çarpanı) için state; viewBox akışı TRANSIENT —
// kare başına setState 380ms boyunca tüm SVG ağacını (~23 render × 81 path)
// yeniden kuruyordu. Artık animasyon svg.setAttribute ile ref üzerinden
// akar, React yalnızca animasyon başında bir kez render eder.
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
const animRef = useRef(0);
// viewBox JSX'te değil: React'in commit'i animasyon karesini ezmesin.
// İlk değer mount'ta bir kez yazılır (stabil callback → yalnızca mount'ta).
const svgMount = useCallback((el: SVGSVGElement | null) => {
svgRef.current = el;
if (el) {
const v = vbRef.current;
el.setAttribute("viewBox", `${v.x} ${v.y} ${v.w} ${v.h}`);
}
}, []);
const animateTo = useCallback((hedef: ViewBox) => {
cancelAnimationFrame(animRef.current);
const kaynak = { ...vbRef.current };
const t0 = performance.now();
const SURE = 380;
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
let ilkKare = true;
const adim = (now: number) => {
if (ilkKare) {
ilkKare = false;
// sf'e bağlı ölçüler tek render'da nihai değere oturur; rAF içinde
// (asenkron) çağrıldığı için effect'te senkron setState sayılmaz.
setVb(hedef);
}
const t = Math.min(1, (now - t0) / SURE);
const k = ease(t);
const sonraki = {
@@ -143,7 +185,10 @@ export function TercihHaritasi({
h: kaynak.h + (hedef.h - kaynak.h) * k,
};
vbRef.current = sonraki;
setVb(sonraki);
svgRef.current?.setAttribute(
"viewBox",
`${sonraki.x} ${sonraki.y} ${sonraki.w} ${sonraki.h}`,
);
if (t < 1) animRef.current = requestAnimationFrame(adim);
};
animRef.current = requestAnimationFrame(adim);
@@ -157,7 +202,7 @@ export function TercihHaritasi({
animateTo(TAM_GORUNUM);
return;
}
const path = pathRefs.current.get(seciliIl);
const path = pathKayit.elemanlar.get(seciliIl);
if (!path) return;
const b = path.getBBox();
const pad = Math.max(b.width, b.height) * 0.22;
@@ -178,7 +223,7 @@ export function TercihHaritasi({
w = yeniW;
}
animateTo({ x, y, w, h });
}, [seciliIl, animateTo]);
}, [seciliIl, animateTo, pathKayit.elemanlar]);
const ilPinleri = useMemo(() => {
const gruplar = new Map<string, HaritaPin[]>();
@@ -194,30 +239,68 @@ export function TercihHaritasi({
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
const sf = vb.w / HARITA_GENISLIK;
const zoomda = Boolean(seciliIl);
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
const acikPinler = cakismaAc(seciliPinler);
const seciliPinler = useMemo(
() => (seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []),
[ilPinleri, seciliIl],
);
const acikPinler = useMemo(() => cakismaAc(seciliPinler), [seciliPinler]);
// Ülke seviyesi toplu pinler: il başına adet/merkez/renk tek geçişte
// (üç ayrı reduce + baskinRisk dördüncü geçişti) ve memo'da.
const ilOzetleri = useMemo(
() =>
[...ilPinleri.entries()].map(([il, grup]) => {
let adet = 0;
let cx = 0;
let cy = 0;
for (const p of grup) {
adet += p.tercihSiralari.length;
cx += p.x;
cy += p.y;
}
return {
il,
adet,
cx: cx / grup.length,
cy: cy / grup.length,
renk: RISK_RENK[baskinRisk(grup)],
};
}),
[ilPinleri],
);
const ilAdetleri = useMemo(
() => new Map(ilOzetleri.map((o) => [o.il, o.adet])),
[ilOzetleri],
);
// Alt şerit: seçili ilin tercihleri sıraya göre (memo'suz flatMap+sort
// her render'da yeniden kuruluyordu)
const seritTercihleri = useMemo(
() =>
seciliPinler
.flatMap((p) => p.tercihSiralari.map((sira) => ({ pin: p, sira })))
.sort((a, b) => a.sira - b.sira),
[seciliPinler],
);
return (
<div>
<div className="relative">
<svg
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
ref={svgMount}
className="h-auto w-full"
role="img"
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
>
{/* İl zeminleri */}
{cities.map((city) => {
const key = normalizeIlAdi(city.name);
const iceriyor = ilPinleri.has(key);
{NORMAL_SEHIRLER.map(({ city, key }) => {
const ilAdet = ilAdetleri.get(key);
const iceriyor = ilAdet !== undefined;
const secili = key === seciliIl;
return (
<path
key={city.id}
ref={(el) => {
if (el) pathRefs.current.set(key, el);
else pathRefs.current.delete(key);
}}
ref={pathKayit.callbacks.get(key)}
d={city.path}
className={
iceriyor
@@ -240,10 +323,7 @@ export function TercihHaritasi({
>
<title>
{iceriyor
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
(a, p) => a + p.tercihSiralari.length,
0,
)} tercih — yakınlaşmak için tıkla`
? `${city.name}: ${ilAdet} tercih — yakınlaşmak için tıkla`
: city.name}
</title>
</path>
@@ -252,47 +332,36 @@ export function TercihHaritasi({
{/* Ülke seviyesi: il başına toplu pin */}
{!zoomda
? [...ilPinleri.entries()].map(([il, iller]) => {
const adet = iller.reduce(
(a, p) => a + p.tercihSiralari.length,
0,
);
const cx =
iller.reduce((a, p) => a + p.x, 0) / iller.length;
const cy =
iller.reduce((a, p) => a + p.y, 0) / iller.length;
const renk = RISK_RENK[baskinRisk(iller)];
return (
<g
key={il}
className="cursor-pointer"
onClick={() => onSeciliIlDegisti(il)}
role="button"
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
? ilOzetleri.map(({ il, adet, cx, cy, renk }) => (
<g
key={il}
className="cursor-pointer"
onClick={() => onSeciliIlDegisti(il)}
role="button"
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
>
<circle
cx={cx}
cy={cy}
r={11}
fill={renk}
stroke="white"
strokeWidth={2}
/>
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
<circle
cx={cx}
cy={cy}
r={11}
fill={renk}
stroke="white"
strokeWidth={2}
/>
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{adet}
</text>
</g>
);
})
{adet}
</text>
</g>
))
: null}
{/* İl seviyesi: üniversite pinleri */}
@@ -426,12 +495,9 @@ export function TercihHaritasi({
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
</p>
<ul className="flex gap-2 overflow-x-auto pb-1">
{seciliPinler
.flatMap((p) =>
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
)
.sort((a, b) => a.sira - b.sira)
.map(({ pin, sira }) => (
{seritTercihleri.map(({ pin, sira }) => {
const logo = pin.kilitli ? null : uniLogoYoluFromAd(pin.universite);
return (
<li key={sira} className="shrink-0">
<button
type="button"
@@ -451,9 +517,9 @@ export function TercihHaritasi({
</span>
) : (
<span className="inline-flex min-w-0 items-center gap-1.5">
{uniLogoYoluFromAd(pin.universite) ? (
{logo ? (
<Image
src={uniLogoYoluFromAd(pin.universite)!}
src={logo}
alt=""
width={16}
height={16}
@@ -468,7 +534,8 @@ export function TercihHaritasi({
)}
</button>
</li>
))}
);
})}
</ul>
</div>
) : null}

View File

@@ -1,8 +1,28 @@
"use client";
import dynamic from "next/dynamic";
// Sıra+sihirbaz kapısı ilk açılış isteğine kadar hiç inmez (bundle-conditional).
// Bir kez açıldıktan sonra mount kalır ki Dialog'un kapanış animasyonu ve
// oturum içi state'i bozulmasın.
export const TercihProfiliKapisiLazy = dynamic(
import { useState } from "react";
import dynamic from "next/dynamic";
import { useProfilKapisi } from "../hooks/use-tercih-profili";
const TercihProfiliKapisiIc = dynamic(
() => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi),
{ ssr: false },
);
/** Kapıyı açan butonların hover/focus'unda chunk'ı önden ısıt. */
export function preloadTercihProfiliKapisi() {
void import("./tercih-profili-kapisi");
}
export function TercihProfiliKapisiLazy() {
const { acik } = useProfilKapisi();
const [gerekli, setGerekli] = useState(false);
// Render sırasında koşullu setState: ilk açılışta mandallanır (React'in
// "adjust state during render" deseni — effect'e gerek yok).
if (acik && !gerekli) setGerekli(true);
return gerekli ? <TercihProfiliKapisiIc /> : null;
}

View File

@@ -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);

View File

@@ -1,12 +1,45 @@
import "server-only";
import { searchByRank } from "@/lib/db";
import type { PuanTuruKey, RankResults } from "@/types/yokatlas";
import type {
Program,
ProgramListeSatiri,
PuanTuruKey,
RankResultsListe,
} from "@/types/yokatlas";
// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin.
// Alanlar tek tek seçilir: Program'a alan eklenirse ProgramListeSatiri
// üzerinden derleyici burada da karar vermeye zorlar.
export function programOzetle(p: Program): ProgramListeSatiri {
return {
id: p.id,
isim: p.isim,
universite: p.universite,
unitur: p.unitur,
il: p.il,
tur: p.tur,
sure: p.sure,
sira2025: p.sira2025,
sira2024: p.sira2024,
sira2023: p.sira2023,
sira2022: p.sira2022,
sira2021: p.sira2021,
kontenjan2025: p.kontenjan2025,
yerlesen2025: p.yerlesen2025,
};
}
// /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
export function siraylaProgramAra(
sira: number,
tur: PuanTuruKey,
): RankResults {
return searchByRank(sira, tur, { limitPerBucket: 20 });
): RankResultsListe {
const ham = searchByRank(sira, tur, { limitPerBucket: 20 });
return {
hayal: ham.hayal.map(programOzetle),
dengeli: ham.dengeli.map(programOzetle),
garanti: ham.garanti.map(programOzetle),
toplam: ham.toplam,
};
}

View File

@@ -87,8 +87,42 @@ export function useCekmeceAcik(): boolean {
);
}
// Üyelik kontrolü O(1): id Set'i tembel kurulur, `liste` her mutasyonda yeni
// referans aldığı için referans karşılaştırması mükemmel geçersizleme anahtarı.
let idSet: Set<string> | null = null;
let idSetKaynak: ManuelTercih[] | null = null;
function idSetAl(): Set<string> {
if (idSetKaynak !== liste || idSet === null) {
idSet = new Set(liste.map((t) => t.id));
idSetKaynak = liste;
}
return idSet;
}
export function listedeMi(id: string): boolean {
return liste.some((t) => t.id === id);
return idSetAl().has(id);
}
/**
* Satır başına üyelik aboneliği (program-ekle-butonu 50+ satırda render
* ediliyor): snapshot primitive boolean olduğu için liste değişse bile yalnız
* üyeliği değişen butonlar yeniden render olur — tüm listeye abone olup her
* mutasyonda 50 satırı uyandırmak yerine.
*/
export function useListedeMi(id: string): boolean {
return useSyncExternalStore(
abone,
() => idSetAl().has(id),
() => false,
);
}
export function useListeDoluMu(): boolean {
return useSyncExternalStore(
abone,
() => liste.length >= MANUEL_LISTE_MAX,
() => false,
);
}
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */

View File

@@ -14,9 +14,11 @@ export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
const geriDonus = siparis
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
: "/paket";
const session = await verifySession(geriDonus);
const order = siparis ? await siparisGetir(siparis) : null;
// Oturum ve sipariş sorgusu bağımsız; sahiplik kontrolü aşağıda yapılıyor
const [session, order] = await Promise.all([
verifySession(geriDonus),
siparis ? siparisGetir(siparis) : Promise.resolve(null),
]);
// Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id;

View File

@@ -14,11 +14,12 @@ export async function baslatOdeme(
_prev: OdemeBaslatDurum,
formData: FormData,
): Promise<OdemeBaslatDurum> {
const session = await verifySession("/paket");
// Ucuz senkron doğrulama pahalı oturum turundan önce
const product = formData.get("urun");
if (product !== "paket" && product !== "topup") {
return { error: "Geçersiz ürün." };
}
const session = await verifySession("/paket");
const urun = URUNLER[product];
const orderId = crypto.randomUUID();

View File

@@ -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<DilimKey, number> } | 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<DilimKey, number>;
};
}>(`/api/facetler?${params}`, { signal });
} catch {
return null;
}

View File

@@ -18,7 +18,7 @@ import {
import { Button } from "@/components/ui/button";
import { RaporListesi } from "./rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { TercihHaritasi } from "@/features/liste/components/tercih-haritasi";
import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporSonuc } from "@/features/rapor/types/rapor";
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
@@ -92,7 +92,7 @@ export function ListemGovde({
className="scroll-mt-24"
>
<div className="mx-auto w-full max-w-xl">
<TercihHaritasi
<TercihHaritasiLazy
pinler={pinVerisi.pinler}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}

View File

@@ -8,7 +8,7 @@
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
// sunucuda zaten maskelidir).
import { Fragment, useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
import { UniLogo } from "@/components/uni-logo";
@@ -121,14 +121,20 @@ export function RaporListesi({
/** Detaydaki "Haritada gör" — il DB biçiminde */
onHaritadaGor?: (il: string | null, sira: number) => void;
}) {
const [acik, setAcik] = useState<Set<number>>(new Set());
// Lazy init: boş Set her render'da boşuna allocate edilmesin
const [acik, setAcik] = useState<Set<number>>(() => new Set());
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
// Haritadan gelen seçim: satırın açılması render sırasındaki önceki-prop
// deseniyle (effect'te setState çift render yaratıyordu); yalnızca DOM işi
// olan scrollIntoView effect'te kalır.
const [oncekiAcikSira, setOncekiAcikSira] = useState(acikSira);
if (acikSira !== oncekiAcikSira) {
setOncekiAcikSira(acikSira);
if (acikSira != null) setAcik((mevcut) => new Set(mevcut).add(acikSira));
}
useEffect(() => {
if (acikSira == null) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAcik((mevcut) => new Set(mevcut).add(acikSira));
satirRefs.current
.get(acikSira)
?.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -145,12 +151,14 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const iller = new Set(
rapor.tercihler
.map((t) => rapor.programlar[t.programId]?.il)
.filter((il): il is string => Boolean(il)),
);
const tekIl = iller.size === 1 ? [...iller][0] : null;
const tekIl = useMemo(() => {
const iller = new Set<string>();
for (const t of rapor.tercihler) {
const il = rapor.programlar[t.programId]?.il;
if (il) iller.add(il);
}
return iller.size === 1 ? [...iller][0] : null;
}, [rapor]);
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId];
@@ -172,7 +180,7 @@ export function RaporListesi({
else satirRefs.current.delete(t.sira);
}}
onClick={() => tiklandi(t.sira)}
className="cursor-pointer"
className="kt-cv-satir cursor-pointer"
>
<TableCell className="pl-4 pr-0">
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">

View File

@@ -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";
@@ -14,6 +14,8 @@ import {
ChatContainerScrollAnchor,
} 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,
@@ -90,12 +92,22 @@ export function SohbetClient({
const [kredi, setKredi] = useState(baslangicKredi);
const router = useRouter();
// Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı
// (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın.
useEffect(() => {
void preloadMarkdown();
}, []);
useEffect(() => {
// 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);
@@ -157,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

View File

@@ -6,7 +6,7 @@
// Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir;
// sayfa paketine girmez.
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import localFont from "next/font/local";
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
@@ -30,38 +30,58 @@ export function ElCizimiSema({
figcaption?: React.ReactNode;
}) {
const [svg, setSvg] = useState<string | null>(null);
const figureRef = useRef<HTMLElement>(null);
// mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz
const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
// ~1 MB'lık mermaid mount'ta değil, şema görünürlüğe yaklaşınca insin —
// şemalar hep fold altında (/meraklisina sayfa sonu, rehber makale ortası).
useEffect(() => {
const el = figureRef.current;
if (!el) return;
let iptal = false;
import("mermaid").then(async ({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
look: "handDrawn",
handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik)
theme: "base",
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
themeVariables: {
primaryColor: "#ffffff",
primaryBorderColor: "#e2e8f0",
primaryTextColor: "#0f172a",
lineColor: "#94a3b8",
edgeLabelBackground: "#ffffff",
const io = new IntersectionObserver(
(girisler) => {
if (!girisler.some((g) => g.isIntersecting)) return;
io.disconnect();
cizdir();
},
// Kullanıcı şemaya varmadan yükleme başlasın
{ rootMargin: "200px 0px" },
);
io.observe(el);
function cizdir() {
import("mermaid").then(async ({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
look: "handDrawn",
handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik)
theme: "base",
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü
},
themeVariables: {
primaryColor: "#ffffff",
primaryBorderColor: "#e2e8f0",
primaryTextColor: "#0f172a",
lineColor: "#94a3b8",
edgeLabelBackground: "#ffffff",
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü
},
});
const sonuc = await mermaid.render(semaId, tanim);
if (!iptal) setSvg(sonuc.svg);
});
const sonuc = await mermaid.render(semaId, tanim);
if (!iptal) setSvg(sonuc.svg);
});
}
return () => {
iptal = true;
io.disconnect();
};
}, [semaId, tanim]);
return (
<figure className="mt-6">
<figure ref={figureRef} className="mt-6">
{svg ? (
<div
role="img"

View File

@@ -7,5 +7,7 @@ export {
getAllRehberler,
getAllRehberSlugs,
getRehber,
getRehberAramaKayitlari,
type RehberOzet,
type RehberAramaKaydi,
} from "@/lib/rehber";

View File

@@ -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);

View File

@@ -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);

View File

@@ -4,6 +4,7 @@
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
import { useMemo } from "react";
import { cities } from "turkey-map-react/lib/data";
import {
HARITA_GENISLIK,
@@ -12,6 +13,14 @@ import {
normalizeIlAdi,
} from "@/lib/harita";
// normalizeIlAdi (3 replace + toLocaleUpperCase) modül yükünde İL BAŞINA BİR
// kez koşar; eskiden her render'da sort comparator içinde ~1000 kez
// çağrılıyordu (81 il × ~çift log n karşılaştırma × 2 çağrı).
const NORMAL_SEHIRLER = cities.map((city) => ({
city,
key: normalizeIlAdi(city.name),
}));
export function IlSecimHaritasi({
iller,
secili,
@@ -22,9 +31,20 @@ export function IlSecimHaritasi({
secili: string[];
onToggle: (il: string) => void;
}) {
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
const maxAdet = Math.max(1, ...iller.map((i) => i.adet));
const seciliSet = new Set(secili);
const { adetler, maxAdet } = useMemo(() => {
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
let maxAdet = 1;
for (const i of iller) if (i.adet > maxAdet) maxAdet = i.adet;
return { adetler, maxAdet };
}, [iller]);
const seciliSet = useMemo(() => new Set(secili), [secili]);
const siraliSehirler = useMemo(
() =>
[...NORMAL_SEHIRLER].sort(
(a, b) => Number(seciliSet.has(a.key)) - Number(seciliSet.has(b.key)),
),
[seciliSet],
);
return (
<div>
@@ -35,14 +55,7 @@ export function IlSecimHaritasi({
aria-label="Okumak istediğin illeri haritadan seç"
>
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
{[...cities]
.sort(
(a, b) =>
Number(seciliSet.has(normalizeIlAdi(a.name))) -
Number(seciliSet.has(normalizeIlAdi(b.name))),
)
.map((city) => {
const key = normalizeIlAdi(city.name);
{siraliSehirler.map(({ city, key }) => {
const adet = adetler.get(key);
const seciliMi = seciliSet.has(key);
const yogunluk = adet ? adet / maxAdet : 0;

View File

@@ -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) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
@@ -286,7 +298,7 @@ export function SihirbazAdimlar({
{/* Harita geniş ekranda görsel destek olarak kalır */}
<div className="mt-4 hidden sm:block">
<IlSecimHaritasi
iller={canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
iller={haritaIlleri}
secili={iller}
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
/>

View File

@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { animate } from "motion";
import { animate } from "motion/mini";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { PixelField } from "@/components/pixel-decor";

View File

@@ -3,7 +3,7 @@
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Trophy } from "lucide-react";
@@ -18,17 +18,14 @@ import {
export function SiraGerekli() {
const router = useRouter();
const { profil, profilYuklendi } = useProfilKapisi();
const [yonleniyor, setYonleniyor] = useState(false);
// "Yönleniyor" durumu ayrı state gerektirmez: profil varken aşağıdaki koşul
// zaten bekleme ekranını gösteriyor; effect yalnızca yönlendirmeyi yapar.
useEffect(() => {
if (!profilYuklendi) return;
if (profil) {
setYonleniyor(true);
router.replace(sonucHref(profil));
}
if (profilYuklendi && profil) router.replace(sonucHref(profil));
}, [profil, profilYuklendi, router]);
if (!profilYuklendi || yonleniyor || profil) {
if (!profilYuklendi || profil) {
return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor…</p>

View File

@@ -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();
}

62
src/lib/istek-cache.ts Normal file
View 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);
});
});
}

View File

@@ -1,4 +1,5 @@
import "server-only";
import { after } from "next/server";
import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { grantCredits, URUNLER } from "./credits";
@@ -62,15 +63,19 @@ export async function odemeyiSonuclandir(
// Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir
// kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner).
await sunucuOlayi("odeme_tamamlandi", {
kullaniciId: order.userId,
pathname: "/odeme/sonuc",
ozellikler: {
urun: order.product,
kredi: order.credits,
tutar: order.amountKurus / 100,
},
});
// after(): yanıt gönderildikten sonra koşar — callback redirect'ini ve
// /odeme/sonuc render'ını Rybbit'in 3 sn'lik timeout'u bloklayamaz.
after(() =>
sunucuOlayi("odeme_tamamlandi", {
kullaniciId: order.userId,
pathname: "/odeme/sonuc",
ozellikler: {
urun: order.product,
kredi: order.credits,
tutar: order.amountKurus / 100,
},
}),
);
return "paid";
}

View File

@@ -25,6 +25,13 @@ export type RehberOzet = {
tarih: string;
};
/**
* Header'daki ⌘K aramasına giden asgari alan seti. `tarih` client'ta hiç
* okunmadığı için RSC payload'ına taşınmaz (root layout üzerinden her
* sayfaya serileşiyordu).
*/
export type RehberAramaKaydi = Pick<RehberOzet, "slug" | "baslik" | "aciklama">;
export type RehberSema = {
/** Mermaid tanımı (aria/altyazi yönergeleri ayıklanmış hâli) */
tanim: string;
@@ -40,6 +47,16 @@ export type RehberYazi = RehberOzet & { parcalar: RehberParca[] };
const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber");
// SiteHeader root layout'ta olduğu için getAllRehberler her sayfa
// render/prerender'ında koşuyor; cache'siz hâli sayfa başına 24 dosya okuma +
// 24 marked.parse demekti. Yalnızca prod'da cache'lenir — dev'de içerik
// düzenlenirken taze fs okuması korunur (katalog.ts/db.ts globalThis deseni).
const globalRehber = globalThis as unknown as {
rehberYazilar?: Map<string, RehberYazi | null>;
rehberOzetler?: RehberOzet[];
};
const CACHE_AKTIF = process.env.NODE_ENV === "production";
function parseFrontmatter(raw: string): {
meta: Record<string, string>;
body: string;
@@ -105,21 +122,62 @@ function parcala(slug: string, body: string): RehberParca[] {
return parcalar;
}
function oku(slug: string): RehberYazi | null {
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
if (!fs.existsSync(dosya)) return null;
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
function metaDogrula(
slug: string,
meta: Record<string, string>,
): asserts meta is { baslik: string; aciklama: string; tarih: string } {
if (!meta.baslik || !meta.aciklama || !meta.tarih) {
throw new Error(
`rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`,
);
}
}
function oku(slug: string): RehberYazi | null {
if (CACHE_AKTIF) {
const onbellek = globalRehber.rehberYazilar?.get(slug);
if (onbellek !== undefined) return onbellek;
}
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
let yazi: RehberYazi | null = null;
if (fs.existsSync(dosya)) {
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
metaDogrula(slug, meta);
yazi = {
slug,
baslik: meta.baslik,
aciklama: meta.aciklama,
tarih: meta.tarih,
parcalar: parcala(slug, body),
};
}
if (CACHE_AKTIF) {
(globalRehber.rehberYazilar ??= new Map()).set(slug, yazi);
}
return yazi;
}
/**
* Liste görünümleri için fast-path: yalnızca frontmatter parse edilir,
* `parcala()`/`marked.parse` hiç çağrılmaz (özetler `parcalar`ı kullanmıyor).
* Tam parse zaten cache'teyse dosyaya inmeden onu kırpar.
*/
function ozetOku(slug: string): RehberOzet | null {
const tam = CACHE_AKTIF ? globalRehber.rehberYazilar?.get(slug) : undefined;
if (tam !== undefined) {
if (tam === null) return null;
const { slug: s, baslik, aciklama, tarih } = tam;
return { slug: s, baslik, aciklama, tarih };
}
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
if (!fs.existsSync(dosya)) return null;
const { meta } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
metaDogrula(slug, meta);
return {
slug,
baslik: meta.baslik,
aciklama: meta.aciklama,
tarih: meta.tarih,
parcalar: parcala(slug, body),
};
}
@@ -139,14 +197,22 @@ export function getRehber(slug: string): RehberYazi | null {
}
export function getAllRehberler(): RehberOzet[] {
return getAllRehberSlugs()
.map((slug) => oku(slug))
.filter((y): y is RehberYazi => y !== null)
.map(({ slug, baslik, aciklama, tarih }) => ({
slug,
baslik,
aciklama,
tarih,
}))
if (CACHE_AKTIF && globalRehber.rehberOzetler) {
return globalRehber.rehberOzetler;
}
const ozetler = getAllRehberSlugs()
.map((slug) => ozetOku(slug))
.filter((y): y is RehberOzet => y !== null)
.sort((a, b) => b.tarih.localeCompare(a.tarih));
if (CACHE_AKTIF) globalRehber.rehberOzetler = ozetler;
return ozetler;
}
/** Header aramasının RSC payload'ına giden kırpılmış liste. */
export function getRehberAramaKayitlari(): RehberAramaKaydi[] {
return getAllRehberler().map(({ slug, baslik, aciklama }) => ({
slug,
baslik,
aciklama,
}));
}

View File

@@ -41,6 +41,20 @@ export type RankResults = {
toplam: Record<DilimKey, number>;
};
/**
* /sonuc ham tablosunun satırı: tablo `fakulte` ve `puan2025`'i hiç
* kullanmadığı için bu alanlar client'a serileştirilmez (satır başına en
* uzun string alanlardan biri fakulte). Katalog sayfaları tam Program kullanır.
*/
export type ProgramListeSatiri = Omit<Program, "fakulte" | "puan2025">;
export type RankResultsListe = {
hayal: ProgramListeSatiri[];
dengeli: ProgramListeSatiri[];
garanti: ProgramListeSatiri[];
toplam: Record<DilimKey, number>;
};
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
export type UniturGrubu = "devlet" | "vakif";