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", "openai",
"resend", "resend",
], ],
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 ...(kaynakKisitli
? { ? {
// cacheComponents açıkken prerender fazında source map üretimi varsayılan
// açık; 1900+ statik sayfada ciddi bellek maliyeti var.
enablePrerenderSourceMaps: false,
experimental: {
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek // Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
// hedefi byte cinsinden ayrıca verilir (1.5 GB). // hedefi byte cinsinden ayrıca verilir (1.5 GB).
turbopackMemoryLimit: 1_610_612_736, turbopackMemoryLimit: 1_610_612_736,
@@ -35,7 +36,14 @@ const nextConfig: NextConfig = {
// worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir. // worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir.
cpus: 1, cpus: 1,
staticGenerationMaxConcurrency: 4, 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() { async redirects() {

View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { dilimAra } from "@/lib/db"; import { dilimAra } from "@/lib/db";
import { programOzetle } from "@/features/liste/liste-queries";
import { import {
PUAN_TURLERI, PUAN_TURLERI,
type DilimKey, type DilimKey,
@@ -39,5 +40,6 @@ export function GET(request: NextRequest) {
limit: SAYFA_BOYU, limit: SAYFA_BOYU,
offset, 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) { 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) { if (!session) {
return Response.json({ error: "Oturum gerekli." }, { status: 401 }); return Response.json({ error: "Oturum gerekli." }, { status: 401 });
} }
if (hamBody === null || typeof hamBody !== "object") {
let body: { message?: unknown; clientMessageId?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: "Geçersiz istek." }, { status: 400 }); 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 mesaj = typeof body.message === "string" ? body.message.trim() : "";
const clientMessageId = const clientMessageId =
typeof body.clientMessageId === "string" ? body.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 }); return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
} }
// Üç 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) // Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
const rapor = await kullanicininRaporu(session.user.id); 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) { if (!rapor?.result) {
return Response.json( return Response.json(
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" }, { error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
{ status: 409 }, { status: 409 },
); );
} }
const gecmis = gecmisHam.reverse();
// 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();
// Kredi düşümü stream'den önce — yetersizse hiç başlama // Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = await spendCreditForMessage({ 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 // 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. // 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 kilitli = !kullanici?.hasPaket;
const tamOzet = listeOzetiCikar( const tamOzet = listeOzetiCikar(
rapor.result as RaporSonuc, 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 { @layer base {
* { * {
@apply border-border outline-ring/50; @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 { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate"; import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41"; 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 { ScrollFlow } from "@/components/scroll-flow";
import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo"; import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo";
import "./globals.css"; 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. // içerikle büyümeli, viewport tabanı body'deki min-h-dvh'den gelir.
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`} 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"> <body className="min-h-dvh flex flex-col bg-slate-50">
<HeaderGate> <HeaderGate>
{/* Site geneli progressive blur — üst ve alt */} {/* Site geneli progressive blur — üst ve alt */}
@@ -130,10 +117,23 @@ export default function RootLayout({
{/* İlk "+" için sıra + sihirbaz kapısı */} {/* İlk "+" için sıra + sihirbaz kapısı */}
<TercihProfiliKapisiLazy /> <TercihProfiliKapisiLazy />
<Toaster position="top-center" /> <Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */} {/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null} {process.env.NODE_ENV !== "production" ? <DevPanelMount /> : null}
<JsonLd data={organizationJsonLd()} /> <JsonLd data={organizationJsonLd()} />
<JsonLd data={webSiteJsonLd()} /> <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> </body>
</html> </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"; "use client";
import { useRef, type ReactNode } from "react"; import { useMemo, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePixelHeat } from "@/components/use-pixel-heat"; import { usePixelHeat } from "@/components/use-pixel-heat";
@@ -88,11 +88,15 @@ export function PixelField({
const COLS = 42; const COLS = 42;
const ROWS = 12; const ROWS = 12;
// Çı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 cx = (COLS - 1) / 2;
const cy = (ROWS - 1) / 2; const cy = (ROWS - 1) / 2;
const maxD = Math.sqrt(cx * cx + cy * cy); const maxD = Math.sqrt(cx * cx + cy * cy);
const pixels: ReactNode[] = []; const sonuc: ReactNode[] = [];
let accentIdx = 0; let accentIdx = 0;
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon // Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
@@ -108,7 +112,7 @@ export function PixelField({
const density = 0.1 + 0.42 * d * d; const density = 0.1 + 0.42 * d * d;
if (hash(x, y, seed) > density) continue; if (hash(x, y, seed) > density) continue;
const accent = hash(x, y, seed + 99) > 0.96; const accent = hash(x, y, seed + 99) > 0.96;
pixels.push( sonuc.push(
pixelRect(x, y, { pixelRect(x, y, {
key: `${x}-${y}`, key: `${x}-${y}`,
accent, accent,
@@ -117,6 +121,8 @@ export function PixelField({
); );
} }
} }
return sonuc;
}, [seed]);
return ( return (
<svg <svg
@@ -149,9 +155,11 @@ export function PixelDivider({
const COLS = 12; const COLS = 12;
const ROWS = 2; const ROWS = 2;
const cx = (COLS - 1) / 2;
const pixels: ReactNode[] = []; // Deterministik çıktı — render başına yeniden üretme (PixelField ile aynı)
const pixels = useMemo(() => {
const cx = (COLS - 1) / 2;
const sonuc: ReactNode[] = [];
let accentPlaced = false; let accentPlaced = false;
for (let y = 0; y < ROWS; y++) { for (let y = 0; y < ROWS; y++) {
@@ -163,7 +171,7 @@ export function PixelDivider({
if (hash(x, y, seed) > density) continue; if (hash(x, y, seed) > density) continue;
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8; const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
if (accent) accentPlaced = true; if (accent) accentPlaced = true;
pixels.push( sonuc.push(
pixelRect(x, y, { pixelRect(x, y, {
key: `${x}-${y}`, key: `${x}-${y}`,
accent, accent,
@@ -171,6 +179,8 @@ export function PixelDivider({
); );
} }
} }
return sonuc;
}, [seed]);
return ( return (
<svg <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. */ /** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) { 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], [2021, program.sira2021],
[2022, program.sira2022], [2022, program.sira2022],
[2023, program.sira2023], [2023, program.sira2023],
[2024, program.sira2024], [2024, program.sira2024],
[2025, program.sira2025], [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>; 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 genislik = 56;
const yukseklik = 18; const yukseklik = 18;
const bosluk = 2; 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 noktalar = yillar.map(([yil, sira], index) => {
const x = const x =
bosluk + bosluk +
@@ -54,9 +64,7 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
return { x, y, yil, sira }; return { x, y, yil, sira };
}); });
const son = noktalar[noktalar.length - 1]; const son = noktalar[noktalar.length - 1];
const ozet = yillar const ozet = ozetParcalari.join("\n");
.map(([yil, sira]) => `${yil}: ~${sira.toLocaleString("tr-TR")}.`)
.join("\n");
return ( return (
<svg <svg

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { startTransition, useEffect, useState } from "react";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react"; import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { CalendarDays, Clock3 } from "lucide-react"; import { CalendarDays, Clock3 } from "lucide-react";
@@ -47,7 +47,9 @@ export function SiteTopBanner() {
const [geriSayim, setGeriSayim] = useState<GeriSayim | null>(null); const [geriSayim, setGeriSayim] = useState<GeriSayim | null>(null);
useEffect(() => { 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(); guncelle();
const zamanlayici = window.setInterval(guncelle, 1_000); 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 { cn } from "@/lib/utils"
import { marked } from "marked"
import { memo, useId, useMemo } from "react" import { memo, useId, useMemo } from "react"
import ReactMarkdown, { Components } from "react-markdown" import ReactMarkdown, { Components } from "react-markdown"
import remarkBreaks from "remark-breaks" import remarkBreaks from "remark-breaks"
@@ -13,9 +12,27 @@ export type MarkdownProps = {
components?: Partial<Components> 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[] { function parseMarkdownIntoBlocks(markdown: string): string[] {
const tokens = marked.lexer(markdown) const bloklar: string[] = []
return tokens.map((token) => token.raw) 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 { function extractLanguage(className?: string): string {

View File

@@ -6,7 +6,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Markdown } from "./markdown" import { MarkdownLazy } from "./markdown-lazy"
import type { MarkdownProps } from "./markdown"
export type MessageProps = { export type MessageProps = {
children: React.ReactNode children: React.ReactNode
@@ -48,7 +49,7 @@ export type MessageContentProps = {
children: React.ReactNode children: React.ReactNode
markdown?: boolean markdown?: boolean
className?: string className?: string
} & React.ComponentProps<typeof Markdown> & } & MarkdownProps &
React.HTMLProps<HTMLDivElement> React.HTMLProps<HTMLDivElement>
const MessageContent = ({ const MessageContent = ({
@@ -63,9 +64,15 @@ const MessageContent = ({
) )
return markdown ? ( 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} {children as string}
</Markdown> </MarkdownLazy>
) : ( ) : (
<div className={classNames} {...props}> <div className={classNames} {...props}>
{children} {children}

View File

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

View File

@@ -16,7 +16,11 @@
*/ */
import { useEffect, type RefObject } from "react"; 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 FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
const HOT_COLOR = "#f97316"; // orange-500 const HOT_COLOR = "#f97316"; // orange-500

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useRef, useState } from "react"; import { startTransition, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/types/yokatlas"; import type { Program } from "@/types/yokatlas";
@@ -104,19 +104,17 @@ export function BolumProgramListesi({
const siraliProgramlar = useMemo(() => { const siraliProgramlar = useMemo(() => {
if (!aktifAlan) return programlar; if (!aktifAlan) return programlar;
return programlar // toSorted stable: eşitlikte orijinal sıra zaten korunur, index
.map((program, index) => ({ program, index })) // dekorasyonu (N obje allocation + 2 ek geçiş) gereksizdi.
.sort((a, b) => { return programlar.toSorted((a, b) => {
const aDeger = a.program[aktifAlan]; const aDeger = a[aktifAlan];
const bDeger = b.program[aktifAlan]; const bDeger = b[aktifAlan];
if (aDeger == null && bDeger == null) return a.index - b.index; if (aDeger == null && bDeger == null) return 0;
if (aDeger == null) return 1; if (aDeger == null) return 1;
if (bDeger == null) return -1; if (bDeger == null) return -1;
const fark = aDeger - bDeger; const fark = aDeger - bDeger;
return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark; return yon === "artan" ? fark : -fark;
}) });
.map(({ program }) => program);
}, [aktifAlan, programlar, yon]); }, [aktifAlan, programlar, yon]);
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa; const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
@@ -129,7 +127,10 @@ export function BolumProgramListesi({
[siraliProgramlar, gosterilenSayfa], [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) { function sirala(alan: SiralamaAlani) {
startTransition(() => {
setIstemciSayfa(1); setIstemciSayfa(1);
if (aktifAlan === alan) { if (aktifAlan === alan) {
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan")); setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
@@ -137,10 +138,11 @@ export function BolumProgramListesi({
} }
setAktifAlan(alan); setAktifAlan(alan);
setYon("artan"); setYon("artan");
});
} }
function istemciSayfaDegistir(n: number) { function istemciSayfaDegistir(n: number) {
setIstemciSayfa(n); startTransition(() => setIstemciSayfa(n));
listeRef.current?.scrollIntoView({ block: "start" }); listeRef.current?.scrollIntoView({ block: "start" });
} }
@@ -193,7 +195,7 @@ export function BolumProgramListesi({
const devlet = p.unitur === "DEVLET"; const devlet = p.unitur === "DEVLET";
return ( return (
<TableRow key={p.id}> <TableRow key={p.id} className="kt-cv-satir">
<TableCell className="max-w-0 w-full"> <TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5"> <span className="flex min-w-0 items-center gap-2.5">
{p.universiteSlug ? ( {p.universiteSlug ? (

View File

@@ -1,18 +1,29 @@
"use client"; "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 Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AramaFunnelKarti } from "@/components/arama-funnel-karti"; import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
import { RehberKapak } from "@/features/rehber/components/rehber-kapak"; import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama"; import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import type { RehberOzet } from "@/lib/rehber"; import { onbellekliJsonGetir } from "@/lib/istek-cache";
import type { RehberAramaKaydi } from "@/lib/rehber";
import { import {
profilDuzenlemeyiAc, profilDuzenlemeyiAc,
sonucaGitIste, sonucaGitIste,
useTercihProfili, useTercihProfili,
} from "@/features/liste/hooks/use-tercih-profili"; } 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 { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { import {
@@ -60,6 +71,18 @@ const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
const REHBER_SONUC_SINIRI = 4; const REHBER_SONUC_SINIRI = 4;
const sayi = (deger: number) => deger.toLocaleString("tr-TR"); 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 = { const KESFET_TONLARI = {
slate: { slate: {
pencere: "border-slate-200/80 bg-white", pencere: "border-slate-200/80 bg-white",
@@ -81,7 +104,7 @@ const KESFET_TONLARI = {
}, },
} as const; } as const;
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) { export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
const router = useRouter(); const router = useRouter();
const profil = useTercihProfili(); const profil = useTercihProfili();
const [acik, setAcik] = useState(false); const [acik, setAcik] = useState(false);
@@ -94,8 +117,13 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
} }
const [sorgu, setSorgu] = useState(""); const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR); const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
const [yukleniyor, setYukleniyor] = useState(false); // Yükleniyor ayrı state değil türetme: "yazılan sorgu henüz cevaplanmadı".
const [macMi, setMacMi] = useState(true); // 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 inputRef = useRef<HTMLInputElement>(null);
const kaydirmaRef = useRef<HTMLDivElement>(null); const kaydirmaRef = useRef<HTMLDivElement>(null);
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false }); const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
@@ -106,6 +134,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
} | null>(null); } | null>(null);
const temizSorgu = sorgu.trim(); const temizSorgu = sorgu.trim();
const aramaAktif = temizSorgu.length >= 2; const aramaAktif = temizSorgu.length >= 2;
const yukleniyor = aramaAktif && cevaplananSorgu !== temizSorgu;
const kenarBlurGuncelle = useCallback(() => { const kenarBlurGuncelle = useCallback(() => {
const alan = kaydirmaRef.current; const alan = kaydirmaRef.current;
@@ -122,16 +151,16 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const rehberSonuclari = useMemo(() => { const rehberSonuclari = useMemo(() => {
if (!aramaAktif) return []; if (!aramaAktif) return [];
const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80); const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80);
// Puan öğe başına BİR kez hesaplanır (Schwartzian) — sort comparator'ı
// her karşılaştırmada eslesmePuani->aramaNormalize çalıştırıyordu
return rehberler return rehberler
.filter((yazi) => .filter((yazi) =>
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu), aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
) )
.sort( .map((yazi) => ({ yazi, puan: eslesmePuani(yazi.baslik, normalSorgu) }))
(a, b) => .sort((a, b) => a.puan - b.puan)
eslesmePuani(a.baslik, normalSorgu) - .slice(0, REHBER_SONUC_SINIRI)
eslesmePuani(b.baslik, normalSorgu), .map(({ yazi }) => yazi);
)
.slice(0, REHBER_SONUC_SINIRI);
}, [aramaAktif, temizSorgu, rehberler]); }, [aramaAktif, temizSorgu, rehberler]);
const sonucVar = const sonucVar =
@@ -139,40 +168,39 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
sonuclar.universiteler.length > 0 || sonuclar.universiteler.length > 0 ||
rehberSonuclari.length > 0; rehberSonuclari.length > 0;
useEffect(() => { // Listener bir kez bağlanır; güncel `acik` useEffectEvent içinden okunur —
const platform = // modal her açılıp kapandığında remove/add döngüsü dönmesin
(navigator as { userAgentData?: { platform?: string } }).userAgentData // (client-event-listeners)
?.platform ?? const kisayolTetiklendi = useEffectEvent(() => modalDegisti(!acik));
navigator.platform ??
"";
setMacMi(/mac|iphone|ipad/i.test(platform));
}, []);
useEffect(() => { useEffect(() => {
function kisayolDinle(event: KeyboardEvent) { function kisayolDinle(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault(); event.preventDefault();
modalDegisti(!acik); kisayolTetiklendi();
} }
} }
window.addEventListener("keydown", kisayolDinle); window.addEventListener("keydown", kisayolDinle);
return () => window.removeEventListener("keydown", kisayolDinle); return () => window.removeEventListener("keydown", kisayolDinle);
}, [acik]); }, []);
useEffect(() => { useEffect(() => {
if (!acik || !aramaAktif) return; if (!acik || !aramaAktif) return;
const controller = new AbortController(); const controller = new AbortController();
const zamanlayici = window.setTimeout(async () => { const zamanlayici = window.setTimeout(async () => {
setYukleniyor(true);
try { try {
const response = await fetch( // Dedup+TTL cache: aynı sorgu ("psikoloji" yazıp silip tekrar yazan
// kullanıcı) ağa çıkmadan bellekten döner. İptal yalnızca bu
// bekleyişi düşürür; paylaşılan fetch sürer.
const veri = await onbellekliJsonGetir<AramaSonuclari>(
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`, `/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
{ signal: controller.signal }, { signal: controller.signal },
); );
if (!response.ok) throw new Error("Arama başarısız"); // Sonuç listesi acil değil — input yazımıyla yarışmasın
const veri = (await response.json()) as AramaSonuclari; startTransition(() => {
setSonuclar(veri); setSonuclar(veri);
setCevaplananSorgu(temizSorgu);
});
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda // Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız. // (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
sonAramaRef.current = { sonAramaRef.current = {
@@ -181,10 +209,11 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
}; };
} catch (hata) { } catch (hata) {
if (!(hata instanceof DOMException && hata.name === "AbortError")) { if (!(hata instanceof DOMException && hata.name === "AbortError")) {
startTransition(() => {
setSonuclar(BOS_SONUCLAR); setSonuclar(BOS_SONUCLAR);
setCevaplananSorgu(temizSorgu);
});
} }
} finally {
if (!controller.signal.aborted) setYukleniyor(false);
} }
}, 160); }, 160);
@@ -217,7 +246,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
if (yeniAcik) { if (yeniAcik) {
setSorgu(""); setSorgu("");
setSonuclar(BOS_SONUCLAR); setSonuclar(BOS_SONUCLAR);
setYukleniyor(false); setCevaplananSorgu("");
setKenarBlur({ ust: false, alt: false }); setKenarBlur({ ust: false, alt: false });
} }
} }
@@ -243,6 +272,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<button <button
type="button" type="button"
onClick={profilDuzenlemeyiAc} 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})`} 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" 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 <button
type="button" type="button"
onClick={siralamaGirVeyaSonucaGit} 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" 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 /> <Trophy className="size-4 text-orange-500" aria-hidden />
@@ -304,6 +337,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
<button <button
type="button" type="button"
onClick={profilDuzenlemeyiAc} 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})`} 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" 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 <button
type="button" type="button"
onClick={siralamaGirVeyaSonucaGit} onClick={siralamaGirVeyaSonucaGit}
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
aria-label="Başarı sıralamanı gir" 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" 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} ref={inputRef}
type="text" type="text"
value={sorgu} value={sorgu}
onChange={(event) => { onChange={(event) => setSorgu(event.target.value)}
const yeniSorgu = event.target.value;
setSorgu(yeniSorgu);
setSonuclar(BOS_SONUCLAR);
setYukleniyor(yeniSorgu.trim().length >= 2);
}}
placeholder="Bölüm, üniversite veya rehber ara..." placeholder="Bölüm, üniversite veya rehber ara..."
aria-label="Bölüm, üniversite veya rehber ara" aria-label="Bölüm, üniversite veya rehber ara"
autoComplete="off" autoComplete="off"
@@ -722,7 +754,7 @@ function RehberSonucKarti({
yazi, yazi,
onSec, onSec,
}: { }: {
yazi: RehberOzet; yazi: RehberAramaKaydi;
onSec: () => void; onSec: () => void;
}) { }) {
return ( return (

View File

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

View File

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

View File

@@ -2,10 +2,22 @@
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline // 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). // 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 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), () => import("./kaydet-banner").then((m) => m.KaydetBanner),
{ ssr: false }, { 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 // Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline
// bundle'a girmemesi için ssr'siz dynamic import (layout server component // 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 dynamic from "next/dynamic";
import { useCekmeceAcik, useManuelListe } from "../liste-store";
export const ListeCekmecesiLazy = dynamic( const ListeCekmecesiIc = dynamic(
() => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi), () => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi),
{ ssr: false }, { 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, Drawer,
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
DrawerDescription,
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
DrawerTitle, DrawerTitle,

View File

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

View File

@@ -5,7 +5,8 @@
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır. // abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
import { useMemo, useState } from "react"; 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 { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import type { RiskSeviyesi } from "@/lib/risk"; import type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "../liste-store"; import { useManuelListe } from "../liste-store";
@@ -53,9 +54,24 @@ export function ManuelHarita() {
return { pinler: [...gruplar.values()], haritaDisi }; return { pinler: [...gruplar.values()], haritaDisi };
}, [liste]); }, [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 ( return (
<div> <div>
<TercihHaritasi <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>
<TercihHaritasiLazy
pinler={pinler} pinler={pinler}
seciliIl={seciliIl} seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl} onSeciliIlDegisti={setSeciliIl}
@@ -65,12 +81,6 @@ export function ManuelHarita() {
{haritaDisi} tercih harita dışında (KKTC vb.) {haritaDisi} tercih harita dışında (KKTC vb.)
</p> </p>
) : null} ) : 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> </div>
); );
} }

View File

@@ -5,12 +5,13 @@ import { toast } from "sonner";
import type { Program } from "@/types/yokatlas"; import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { import {
listedeMi,
MANUEL_LISTE_MAX, MANUEL_LISTE_MAX,
useManuelListe, useListeDoluMu,
useListedeMi,
type ManuelKaynak, type ManuelKaynak,
} from "../liste-store"; } from "../liste-store";
import { programEkleIste } from "../hooks/use-tercih-profili"; import { programEkleIste } from "../hooks/use-tercih-profili";
import { preloadTercihProfiliKapisi } from "./tercih-profili-kapisi-lazy";
type ProgramOzet = Pick< type ProgramOzet = Pick<
Program, Program,
@@ -37,9 +38,11 @@ export function ProgramEkleButonu({
adayTur?: string; adayTur?: string;
className?: string; className?: string;
}) { }) {
const liste = useManuelListe(); // Primitive snapshot'lı selector'lar: liste mutasyonunda yalnız üyeliği
const listede = liste.some((t) => t.id === program.id) || listedeMi(program.id); // değişen buton render olur (eskiden 50 satır × tam liste aboneliği + aynı
const doldu = liste.length >= MANUEL_LISTE_MAX; // taramanın listedeMi ile ikinci kez yapılması vardı).
const listede = useListedeMi(program.id);
const doldu = useListeDoluMu();
function handleClick(e: React.MouseEvent) { function handleClick(e: React.MouseEvent) {
e.preventDefault(); e.preventDefault();
@@ -59,6 +62,9 @@ export function ProgramEkleButonu({
type="button" type="button"
disabled={listede || doldu} disabled={listede || doldu}
onClick={handleClick} onClick={handleClick}
// Profilsiz kullanıcıda + kapıyı açar — chunk'ı niyet anında ısıt
onPointerEnter={preloadTercihProfiliKapisi}
onFocus={preloadTercihProfiliKapisi}
title={ title={
listede listede
? "Zaten seçtiklerinde" ? "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 "+" // 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). // 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 Link from "next/link";
import { import {
ChevronDown, ChevronDown,
@@ -13,7 +13,12 @@ import {
Scale, Scale,
ShieldCheck, ShieldCheck,
} from "lucide-react"; } 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 { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug"; import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
import { import {
@@ -31,6 +36,7 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu"; 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 { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
@@ -70,7 +76,7 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
riskli: "bg-red-500", riskli: "bg-red-500",
}; };
const BOS_EKSTRA: Record<DilimKey, Program[]> = { const BOS_EKSTRA: Record<DilimKey, ProgramListeSatiri[]> = {
hayal: [], hayal: [],
dengeli: [], dengeli: [],
garanti: [], 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 ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır. // "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 return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
? "garanti" ? "garanti"
: "dengeli"; : "dengeli";
@@ -89,7 +95,7 @@ export function ProgramTablosu({
adaySira, adaySira,
tur, tur,
}: { }: {
sonuclar: RankResults; sonuclar: RankResultsListe;
adaySira: number; adaySira: number;
tur: PuanTuruKey; 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) { async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim); setYuklenen(dilim);
try { try {
@@ -120,7 +133,7 @@ export function ProgramTablosu({
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`, `/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
); );
if (!res.ok) return; 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 // Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
setEkstra((mevcut) => { setEkstra((mevcut) => {
const eldekiler = new Set( const eldekiler = new Set(
@@ -141,7 +154,14 @@ export function ProgramTablosu({
} }
return ( 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 <Tabs
value={aktifDilim} value={aktifDilim}
onValueChange={(v) => setAktifDilim(v as DilimKey)} onValueChange={(v) => setAktifDilim(v as DilimKey)}
@@ -161,7 +181,7 @@ export function ProgramTablosu({
</TabsList> </TabsList>
{DILIMLER.map((d) => { {DILIMLER.map((d) => {
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]]; const satirlar = tumSatirlar[d.key];
const toplam = sonuclar.toplam[d.key]; const toplam = sonuclar.toplam[d.key];
return ( return (
<TabsContent key={d.key} value={d.key}> <TabsContent key={d.key} value={d.key}>
@@ -232,7 +252,7 @@ function ProgramSatiri({
adaySira, adaySira,
tur, tur,
}: { }: {
program: Program; program: ProgramListeSatiri;
adaySira: number; adaySira: number;
tur: PuanTuruKey; tur: PuanTuruKey;
}) { }) {
@@ -241,7 +261,7 @@ function ProgramSatiri({
const devlet = p.unitur === "DEVLET"; const devlet = p.unitur === "DEVLET";
return ( return (
<TableRow> <TableRow className="kt-cv-satir">
<TableCell className="pl-4 pr-0"> <TableCell className="pl-4 pr-0">
{risk ? ( {risk ? (
<span <span

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { memo } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
@@ -29,6 +30,7 @@ import {
sonucaGitIste, sonucaGitIste,
useTercihProfili, useTercihProfili,
} from "../hooks/use-tercih-profili"; } from "../hooks/use-tercih-profili";
import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = { const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" }, guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
@@ -176,7 +178,7 @@ export function SecimlerimPaneli() {
<ol className="flex flex-col gap-3"> <ol className="flex flex-col gap-3">
{liste.map((t, i) => ( {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> </ol>
</section> </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, tercih,
sira, sira,
profil,
}: { }: {
tercih: ManuelTercih; tercih: ManuelTercih;
sira: number; sira: number;
profil: TercihProfili | null;
}) { }) {
const profil = useTercihProfili();
const degerlendirme = profil const degerlendirme = profil
? tercihDegerlendir( ? tercihDegerlendir(
{ {
@@ -302,4 +307,4 @@ function SecimSatiri({
</div> </div>
</li> </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; 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" */ /** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
function kisaUniAdi(ad: string): string { function kisaUniAdi(ad: string): string {
const kisa = ad const kisa = ad
@@ -122,18 +128,54 @@ export function TercihHaritasi({
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */ /** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
onTercihSec?: (sira: number) => void; 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 [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
const vbRef = useRef<ViewBox>(TAM_GORUNUM); const vbRef = useRef<ViewBox>(TAM_GORUNUM);
const animRef = useRef(0); 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) => { const animateTo = useCallback((hedef: ViewBox) => {
cancelAnimationFrame(animRef.current); cancelAnimationFrame(animRef.current);
const kaynak = { ...vbRef.current }; const kaynak = { ...vbRef.current };
const t0 = performance.now(); const t0 = performance.now();
const SURE = 380; const SURE = 380;
const ease = (t: number) => 1 - Math.pow(1 - t, 3); const ease = (t: number) => 1 - Math.pow(1 - t, 3);
let ilkKare = true;
const adim = (now: number) => { 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 t = Math.min(1, (now - t0) / SURE);
const k = ease(t); const k = ease(t);
const sonraki = { const sonraki = {
@@ -143,7 +185,10 @@ export function TercihHaritasi({
h: kaynak.h + (hedef.h - kaynak.h) * k, h: kaynak.h + (hedef.h - kaynak.h) * k,
}; };
vbRef.current = sonraki; vbRef.current = sonraki;
setVb(sonraki); svgRef.current?.setAttribute(
"viewBox",
`${sonraki.x} ${sonraki.y} ${sonraki.w} ${sonraki.h}`,
);
if (t < 1) animRef.current = requestAnimationFrame(adim); if (t < 1) animRef.current = requestAnimationFrame(adim);
}; };
animRef.current = requestAnimationFrame(adim); animRef.current = requestAnimationFrame(adim);
@@ -157,7 +202,7 @@ export function TercihHaritasi({
animateTo(TAM_GORUNUM); animateTo(TAM_GORUNUM);
return; return;
} }
const path = pathRefs.current.get(seciliIl); const path = pathKayit.elemanlar.get(seciliIl);
if (!path) return; if (!path) return;
const b = path.getBBox(); const b = path.getBBox();
const pad = Math.max(b.width, b.height) * 0.22; const pad = Math.max(b.width, b.height) * 0.22;
@@ -178,7 +223,7 @@ export function TercihHaritasi({
w = yeniW; w = yeniW;
} }
animateTo({ x, y, w, h }); animateTo({ x, y, w, h });
}, [seciliIl, animateTo]); }, [seciliIl, animateTo, pathKayit.elemanlar]);
const ilPinleri = useMemo(() => { const ilPinleri = useMemo(() => {
const gruplar = new Map<string, HaritaPin[]>(); 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 // Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
const sf = vb.w / HARITA_GENISLIK; const sf = vb.w / HARITA_GENISLIK;
const zoomda = Boolean(seciliIl); const zoomda = Boolean(seciliIl);
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []; const seciliPinler = useMemo(
const acikPinler = cakismaAc(seciliPinler); () => (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 ( return (
<div> <div>
<div className="relative"> <div className="relative">
<svg <svg
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`} ref={svgMount}
className="h-auto w-full" className="h-auto w-full"
role="img" role="img"
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı" aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
> >
{/* İl zeminleri */} {/* İl zeminleri */}
{cities.map((city) => { {NORMAL_SEHIRLER.map(({ city, key }) => {
const key = normalizeIlAdi(city.name); const ilAdet = ilAdetleri.get(key);
const iceriyor = ilPinleri.has(key); const iceriyor = ilAdet !== undefined;
const secili = key === seciliIl; const secili = key === seciliIl;
return ( return (
<path <path
key={city.id} key={city.id}
ref={(el) => { ref={pathKayit.callbacks.get(key)}
if (el) pathRefs.current.set(key, el);
else pathRefs.current.delete(key);
}}
d={city.path} d={city.path}
className={ className={
iceriyor iceriyor
@@ -240,10 +323,7 @@ export function TercihHaritasi({
> >
<title> <title>
{iceriyor {iceriyor
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce( ? `${city.name}: ${ilAdet} tercih — yakınlaşmak için tıkla`
(a, p) => a + p.tercihSiralari.length,
0,
)} tercih — yakınlaşmak için tıkla`
: city.name} : city.name}
</title> </title>
</path> </path>
@@ -252,17 +332,7 @@ export function TercihHaritasi({
{/* Ülke seviyesi: il başına toplu pin */} {/* Ülke seviyesi: il başına toplu pin */}
{!zoomda {!zoomda
? [...ilPinleri.entries()].map(([il, iller]) => { ? ilOzetleri.map(({ il, adet, cx, cy, renk }) => (
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 <g
key={il} key={il}
className="cursor-pointer" className="cursor-pointer"
@@ -291,8 +361,7 @@ export function TercihHaritasi({
{adet} {adet}
</text> </text>
</g> </g>
); ))
})
: null} : null}
{/* İl seviyesi: üniversite pinleri */} {/* İl seviyesi: üniversite pinleri */}
@@ -426,12 +495,9 @@ export function TercihHaritasi({
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin {seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
</p> </p>
<ul className="flex gap-2 overflow-x-auto pb-1"> <ul className="flex gap-2 overflow-x-auto pb-1">
{seciliPinler {seritTercihleri.map(({ pin, sira }) => {
.flatMap((p) => const logo = pin.kilitli ? null : uniLogoYoluFromAd(pin.universite);
p.tercihSiralari.map((sira) => ({ pin: p, sira })), return (
)
.sort((a, b) => a.sira - b.sira)
.map(({ pin, sira }) => (
<li key={sira} className="shrink-0"> <li key={sira} className="shrink-0">
<button <button
type="button" type="button"
@@ -451,9 +517,9 @@ export function TercihHaritasi({
</span> </span>
) : ( ) : (
<span className="inline-flex min-w-0 items-center gap-1.5"> <span className="inline-flex min-w-0 items-center gap-1.5">
{uniLogoYoluFromAd(pin.universite) ? ( {logo ? (
<Image <Image
src={uniLogoYoluFromAd(pin.universite)!} src={logo}
alt="" alt=""
width={16} width={16}
height={16} height={16}
@@ -468,7 +534,8 @@ export function TercihHaritasi({
)} )}
</button> </button>
</li> </li>
))} );
})}
</ul> </ul>
</div> </div>
) : null} ) : null}

View File

@@ -1,8 +1,28 @@
"use client"; "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), () => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi),
{ ssr: false }, { 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"; } from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/types/yokatlas"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil"; import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
@@ -145,12 +146,12 @@ function KapıIcerik({
setYukleniyor(true); setYukleniyor(true);
setHata(null); setHata(null);
try { try {
const [res, oturum] = await Promise.all([ const [veri, oturum] = await Promise.all([
fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`), onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
),
authClient.getSession().catch(() => null), authClient.getSession().catch(() => null),
]); ]);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setGirisli(Boolean(oturum?.data?.user)); setGirisli(Boolean(oturum?.data?.user));
setSira(siraDeger); setSira(siraDeger);
setTur(turDeger); setTur(turDeger);

View File

@@ -1,12 +1,45 @@
import "server-only"; import "server-only";
import { searchByRank } from "@/lib/db"; 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, // /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar,
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır. // hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
export function siraylaProgramAra( export function siraylaProgramAra(
sira: number, sira: number,
tur: PuanTuruKey, tur: PuanTuruKey,
): RankResults { ): RankResultsListe {
return searchByRank(sira, tur, { limitPerBucket: 20 }); 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 { 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. */ /** 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 const geriDonus = siparis
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}` ? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
: "/paket"; : "/paket";
const session = await verifySession(geriDonus); // Oturum ve sipariş sorgusu bağımsız; sahiplik kontrolü aşağıda yapılıyor
const [session, order] = await Promise.all([
const order = siparis ? await siparisGetir(siparis) : null; verifySession(geriDonus),
siparis ? siparisGetir(siparis) : Promise.resolve(null),
]);
// Yalnızca kendi siparişi görüntülenebilir // Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id; const sahibiMi = order?.userId === session.user.id;

View File

@@ -14,11 +14,12 @@ export async function baslatOdeme(
_prev: OdemeBaslatDurum, _prev: OdemeBaslatDurum,
formData: FormData, formData: FormData,
): Promise<OdemeBaslatDurum> { ): Promise<OdemeBaslatDurum> {
const session = await verifySession("/paket"); // Ucuz senkron doğrulama pahalı oturum turundan önce
const product = formData.get("urun"); const product = formData.get("urun");
if (product !== "paket" && product !== "topup") { if (product !== "paket" && product !== "topup") {
return { error: "Geçersiz ürün." }; return { error: "Geçersiz ürün." };
} }
const session = await verifySession("/paket");
const urun = URUNLER[product]; const urun = URUNLER[product];
const orderId = crypto.randomUUID(); 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 { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler"; import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas"; import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
export type DemoVeri = { export type DemoVeri = {
facetler: SihirbazFacetleri; facetler: SihirbazFacetleri;
@@ -48,12 +49,11 @@ async function facetGetir(
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> { ): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" }); const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
try { try {
const res = await fetch(`/api/facetler?${params}`, { signal }); // Demo slider'ı aynı sıraya dönünce cache'ten okur (dedup+TTL)
if (!res.ok) return null; return await onbellekliJsonGetir<{
return (await res.json()) as {
facetler: SihirbazFacetleri; facetler: SihirbazFacetleri;
dilimToplam?: Record<DilimKey, number>; dilimToplam?: Record<DilimKey, number>;
}; }>(`/api/facetler?${params}`, { signal });
} catch { } catch {
return null; return null;
} }

View File

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

View File

@@ -8,7 +8,7 @@
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin // açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
// sunucuda zaten maskelidir). // sunucuda zaten maskelidir).
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react"; import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
@@ -121,14 +121,20 @@ export function RaporListesi({
/** Detaydaki "Haritada gör" — il DB biçiminde */ /** Detaydaki "Haritada gör" — il DB biçiminde */
onHaritadaGor?: (il: string | null, sira: number) => void; 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>()); 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(() => { useEffect(() => {
if (acikSira == null) return; if (acikSira == null) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAcik((mevcut) => new Set(mevcut).add(acikSira));
satirRefs.current satirRefs.current
.get(acikSira) .get(acikSira)
?.scrollIntoView({ behavior: "smooth", block: "center" }); ?.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -145,12 +151,14 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length; const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const iller = new Set( const tekIl = useMemo(() => {
rapor.tercihler const iller = new Set<string>();
.map((t) => rapor.programlar[t.programId]?.il) for (const t of rapor.tercihler) {
.filter((il): il is string => Boolean(il)), const il = rapor.programlar[t.programId]?.il;
); if (il) iller.add(il);
const tekIl = iller.size === 1 ? [...iller][0] : null; }
return iller.size === 1 ? [...iller][0] : null;
}, [rapor]);
const satir = (t: RaporSonuc["tercihler"][number], index: number) => { const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId]; const p = rapor.programlar[t.programId];
@@ -172,7 +180,7 @@ export function RaporListesi({
else satirRefs.current.delete(t.sira); else satirRefs.current.delete(t.sira);
}} }}
onClick={() => tiklandi(t.sira)} onClick={() => tiklandi(t.sira)}
className="cursor-pointer" className="kt-cv-satir cursor-pointer"
> >
<TableCell className="pl-4 pr-0"> <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"> <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"; "use client";
import { useEffect, useState } from "react"; import { startTransition, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
@@ -14,6 +14,8 @@ import {
ChatContainerScrollAnchor, ChatContainerScrollAnchor,
} from "@/components/ui/chat-container"; } from "@/components/ui/chat-container";
import { Message, MessageContent } from "@/components/ui/message"; import { Message, MessageContent } from "@/components/ui/message";
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { import {
PromptInput, PromptInput,
PromptInputAction, PromptInputAction,
@@ -90,12 +92,22 @@ export function SohbetClient({
const [kredi, setKredi] = useState(baslangicKredi); const [kredi, setKredi] = useState(baslangicKredi);
const router = useRouter(); 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(() => { useEffect(() => {
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
// popup gibi geç mount olan yerlerde eski davranış sürer. // popup gibi geç mount olan yerlerde eski davranış sürer.
if (initialMesajlar != null) return; if (initialMesajlar != null) return;
fetch("/api/soru") // ttlMs: 0 = yalnız in-flight dedup — /sonuc popup'ı ile /listem aynı anda
.then((res) => (res.ok ? res.json() : null)) // mount olursa tek istek; kredi/geçmiş POST sonrası değiştiği için
// TTL cache'i bilinçli kapalı.
onbellekliJsonGetir<{ mesajlar?: Mesaj[]; kredi?: number }>("/api/soru", {
ttlMs: 0,
})
.then((data) => { .then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar); if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") setKredi(data.kredi); if (typeof data?.kredi === "number") setKredi(data.kredi);
@@ -157,8 +169,12 @@ export function SohbetClient({
if (done) break; if (done) break;
birikmis += decoder.decode(value, { stream: true }); birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis; const anlik = birikmis;
// Chunk başına gelen güncelleme acil değil: transition'a alınca
// kullanıcı stream sürerken input'a takılmadan yazabilir
startTransition(() =>
setMesajlar((m) => setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)), m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
),
); );
} }
// Header'daki kredi pill'i layout'ta server-render edilir ve client // Header'daki kredi pill'i layout'ta server-render edilir ve client

View File

@@ -6,7 +6,7 @@
// Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir; // Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir;
// sayfa paketine girmez. // sayfa paketine girmez.
import { useEffect, useId, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import localFont from "next/font/local"; import localFont from "next/font/local";
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber // Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
@@ -30,11 +30,28 @@ export function ElCizimiSema({
figcaption?: React.ReactNode; figcaption?: React.ReactNode;
}) { }) {
const [svg, setSvg] = useState<string | null>(null); 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 // mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz
const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; 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(() => { useEffect(() => {
const el = figureRef.current;
if (!el) return;
let iptal = false; let iptal = false;
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 }) => { import("mermaid").then(async ({ default: mermaid }) => {
mermaid.initialize({ mermaid.initialize({
startOnLoad: false, startOnLoad: false,
@@ -55,13 +72,16 @@ export function ElCizimiSema({
const sonuc = await mermaid.render(semaId, tanim); const sonuc = await mermaid.render(semaId, tanim);
if (!iptal) setSvg(sonuc.svg); if (!iptal) setSvg(sonuc.svg);
}); });
}
return () => { return () => {
iptal = true; iptal = true;
io.disconnect();
}; };
}, [semaId, tanim]); }, [semaId, tanim]);
return ( return (
<figure className="mt-6"> <figure ref={figureRef} className="mt-6">
{svg ? ( {svg ? (
<div <div
role="img" role="img"

View File

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

View File

@@ -27,6 +27,7 @@ import {
} from "./sihirbaz-adimlar-lazy"; } from "./sihirbaz-adimlar-lazy";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili"; import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
import type { SihirbazFacetleri } from "@/types/yokatlas"; import type { SihirbazFacetleri } from "@/types/yokatlas";
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler"; import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
@@ -91,12 +92,12 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
try { try {
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır // Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
// (hero formundaki desenle aynı — bkz. hero-form.tsx). // (hero formundaki desenle aynı — bkz. hero-form.tsx).
const [res, oturum] = await Promise.all([ const [veri, oturum] = await Promise.all([
fetch(`/api/facetler?sira=${value}&tur=${tur}`), onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
`/api/facetler?sira=${value}&tur=${tur}`,
),
authClient.getSession().catch(() => null), authClient.getSession().catch(() => null),
]); ]);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setGirisli(Boolean(oturum?.data?.user)); setGirisli(Boolean(oturum?.data?.user));
setSira(value); setSira(value);
setFacetler(veri.facetler); setFacetler(veri.facetler);

View File

@@ -18,6 +18,7 @@ import {
} from "./sihirbaz-adimlar-lazy"; } from "./sihirbaz-adimlar-lazy";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { olay, siraKovasi } from "@/lib/analitik"; import { olay, siraKovasi } from "@/lib/analitik";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { import {
profilDuzenlemeyiAc, profilDuzenlemeyiAc,
useTercihProfili, useTercihProfili,
@@ -67,12 +68,13 @@ export function HeroForm() {
try { try {
// Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür: // Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür:
// landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır). // landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır).
const [res, oturum] = await Promise.all([ // Facetler dedup+TTL cache'li: aynı sıra/tür tekrar girilirse ağa çıkmaz.
fetch(`/api/facetler?sira=${value}&tur=${tur}`), const [veri, oturum] = await Promise.all([
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
`/api/facetler?sira=${value}&tur=${tur}`,
),
authClient.getSession().catch(() => null), authClient.getSession().catch(() => null),
]); ]);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setGirisli(Boolean(oturum?.data?.user)); setGirisli(Boolean(oturum?.data?.user));
setSira(value); setSira(value);
setFacetler(veri.facetler); setFacetler(veri.facetler);

View File

@@ -4,6 +4,7 @@
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu // 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. // 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 { cities } from "turkey-map-react/lib/data";
import { import {
HARITA_GENISLIK, HARITA_GENISLIK,
@@ -12,6 +13,14 @@ import {
normalizeIlAdi, normalizeIlAdi,
} from "@/lib/harita"; } 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({ export function IlSecimHaritasi({
iller, iller,
secili, secili,
@@ -22,9 +31,20 @@ export function IlSecimHaritasi({
secili: string[]; secili: string[];
onToggle: (il: string) => void; onToggle: (il: string) => void;
}) { }) {
const { adetler, maxAdet } = useMemo(() => {
const adetler = new Map(iller.map((i) => [i.il, i.adet])); const adetler = new Map(iller.map((i) => [i.il, i.adet]));
const maxAdet = Math.max(1, ...iller.map((i) => i.adet)); let maxAdet = 1;
const seciliSet = new Set(secili); 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 ( return (
<div> <div>
@@ -35,14 +55,7 @@ export function IlSecimHaritasi({
aria-label="Okumak istediğin illeri haritadan seç" aria-label="Okumak istediğin illeri haritadan seç"
> >
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */} {/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
{[...cities] {siraliSehirler.map(({ city, key }) => {
.sort(
(a, b) =>
Number(seciliSet.has(normalizeIlAdi(a.name))) -
Number(seciliSet.has(normalizeIlAdi(b.name))),
)
.map((city) => {
const key = normalizeIlAdi(city.name);
const adet = adetler.get(key); const adet = adetler.get(key);
const seciliMi = seciliSet.has(key); const seciliMi = seciliSet.has(key);
const yogunluk = adet ? adet / maxAdet : 0; 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 // Adımlar kademeli süzülür: 2. adımın illeri seçili kategorilere, 3. adımın
// üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir. // üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir.
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, X } from "lucide-react"; import { ArrowLeft, ArrowRight, X } from "lucide-react";
import { useReducedMotion } from "motion/react"; import { useReducedMotion } from "motion/react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
@@ -17,6 +17,7 @@ import { TransitionPanel } from "@/components/core/transition-panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { IlSecimHaritasi } from "./il-secim-haritasi"; import { IlSecimHaritasi } from "./il-secim-haritasi";
import { normalizeIlAdi } from "@/lib/harita"; import { normalizeIlAdi } from "@/lib/harita";
import { onbellekliJsonGetir } from "@/lib/istek-cache";
import { olay } from "@/lib/analitik"; import { olay } from "@/lib/analitik";
import { trBaslikDuzeni } from "@/lib/slug"; import { trBaslikDuzeni } from "@/lib/slug";
import type { SihirbazFacetleri } from "@/types/yokatlas"; import type { SihirbazFacetleri } from "@/types/yokatlas";
@@ -85,6 +86,17 @@ export function SihirbazAdimlar({
// tipi sayıları adıma girerken o anki seçimlerle tazelenir. Fetch başarısız // tipi sayıları adıma girerken o anki seçimlerle tazelenir. Fetch başarısız
// olursa eldeki (daha geniş) liste kalır — sihirbaz asla kilitlenmez. // olursa eldeki (daha geniş) liste kalır — sihirbaz asla kilitlenmez.
const [canliFacetler, setCanliFacetler] = useState(facetler); const [canliFacetler, setCanliFacetler] = useState(facetler);
// 81 elemanlı listeyi her çip tıklamasında yeniden sıralamamak/filtrelememek
// için memo; haritaya giden dizinin referansı da sabit kalır (gereksiz
// IlSecimHaritasi render'ı biter).
const siraliIller = useMemo(
() => [...canliFacetler.iller].sort((a, b) => b.adet - a.adet),
[canliFacetler.iller],
);
const haritaIlleri = useMemo(
() => canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il)),
[canliFacetler.iller],
);
const [facetYukleniyor, setFacetYukleniyor] = useState(false); const [facetYukleniyor, setFacetYukleniyor] = useState(false);
useEffect(() => { useEffect(() => {
if (adim === 0) return; if (adim === 0) return;
@@ -94,10 +106,12 @@ export function SihirbazAdimlar({
if (adim === 2) for (const i of iller) params.append("il", i); if (adim === 2) for (const i of iller) params.append("il", i);
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
setFacetYukleniyor(true); setFacetYukleniyor(true);
fetch(`/api/facetler?${params}`, { signal: ctrl.signal }) // Cache'li: 2↔3 adımları arasında gidip gelmek aynı URL'i yeniden çekmez
.then((res) => (res.ok ? res.json() : null)) onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
.then((veri: { facetler: SihirbazFacetleri } | null) => { `/api/facetler?${params}`,
if (!veri) return; { signal: ctrl.signal },
)
.then((veri) => {
setCanliFacetler((mevcut) => ({ setCanliFacetler((mevcut) => ({
...mevcut, ...mevcut,
iller: veri.facetler.iller, iller: veri.facetler.iller,
@@ -250,9 +264,7 @@ export function SihirbazAdimlar({
facetYukleniyor ? "animate-pulse opacity-60" : "" facetYukleniyor ? "animate-pulse opacity-60" : ""
}`} }`}
> >
{[...canliFacetler.iller] {siraliIller.map((i) => (
.sort((a, b) => b.adet - a.adet)
.map((i) => (
<Cip <Cip
key={i.il} key={i.il}
secili={iller.includes(i.il)} secili={iller.includes(i.il)}
@@ -286,7 +298,7 @@ export function SihirbazAdimlar({
{/* Harita geniş ekranda görsel destek olarak kalır */} {/* Harita geniş ekranda görsel destek olarak kalır */}
<div className="mt-4 hidden sm:block"> <div className="mt-4 hidden sm:block">
<IlSecimHaritasi <IlSecimHaritasi
iller={canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il))} iller={haritaIlleri}
secili={iller} secili={iller}
onToggle={(il) => listeDegistir(iller, setIller, il, 5)} onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
/> />

View File

@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { animate } from "motion"; import { animate } from "motion/mini";
import { useReducedMotion } from "motion/react"; import { useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PixelField } from "@/components/pixel-decor"; 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ç. // /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak. // 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 { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Trophy } from "lucide-react"; import { ArrowLeft, Trophy } from "lucide-react";
@@ -18,17 +18,14 @@ import {
export function SiraGerekli() { export function SiraGerekli() {
const router = useRouter(); const router = useRouter();
const { profil, profilYuklendi } = useProfilKapisi(); 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(() => { useEffect(() => {
if (!profilYuklendi) return; if (profilYuklendi && profil) router.replace(sonucHref(profil));
if (profil) {
setYonleniyor(true);
router.replace(sonucHref(profil));
}
}, [profil, profilYuklendi, router]); }, [profil, profilYuklendi, router]);
if (!profilYuklendi || yonleniyor || profil) { if (!profilYuklendi || profil) {
return ( return (
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center"> <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> <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)); window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT));
} }
// tercihProfiliOku 18 farklı yerden çağrılıyor ve tek bir "+" tıklaması bile
// 3 kez okuyabiliyor; her çağrıda localStorage + JSON.parse + şema doğrulama
// yapmamak için modül seviyesi cache (js-cache-storage). Geçersizleme iki
// kanaldan: aynı sekmede PROFIL_DEGISTI_EVENT, diğer sekmelerde "storage".
// undefined = henüz okunmadı, null = okundu ve profil yok.
let profilCache: TercihProfili | null | undefined;
if (typeof window !== "undefined") {
window.addEventListener(PROFIL_DEGISTI_EVENT, () => {
profilCache = undefined;
});
window.addEventListener("storage", (e) => {
if (e.key === SIHIRBAZ_STORAGE_KEY || e.key === null) {
profilCache = undefined;
}
});
}
/** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */ /** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */
export function tercihProfiliOku(): TercihProfili | null { export function tercihProfiliOku(): TercihProfili | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
if (profilCache !== undefined) return profilCache;
try { try {
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY); const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
if (!ham) return null; profilCache = ham ? tercihProfiliDogrula(JSON.parse(ham)) : null;
return tercihProfiliDogrula(JSON.parse(ham));
} catch { } catch {
return null; profilCache = null;
} }
return profilCache;
} }
/** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */ /** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */
@@ -32,6 +50,7 @@ export function tercihProfiliYaz(profil: TercihProfili): void {
try { try {
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru)); localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
} catch {} } catch {}
profilCache = dogru;
profilDegistiginiYayinla(); profilDegistiginiYayinla();
} }
@@ -41,6 +60,7 @@ export function tercihProfiliSil(): void {
try { try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY); localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {} } catch {}
profilCache = null;
profilDegistiginiYayinla(); profilDegistiginiYayinla();
} }

62
src/lib/istek-cache.ts Normal file
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 "server-only";
import { after } from "next/server";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb"; import { appDb, schema } from "./appdb";
import { grantCredits, URUNLER } from "./credits"; import { grantCredits, URUNLER } from "./credits";
@@ -62,7 +63,10 @@ export async function odemeyiSonuclandir(
// Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir // 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). // kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner).
await sunucuOlayi("odeme_tamamlandi", { // 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, kullaniciId: order.userId,
pathname: "/odeme/sonuc", pathname: "/odeme/sonuc",
ozellikler: { ozellikler: {
@@ -70,7 +74,8 @@ export async function odemeyiSonuclandir(
kredi: order.credits, kredi: order.credits,
tutar: order.amountKurus / 100, tutar: order.amountKurus / 100,
}, },
}); }),
);
return "paid"; return "paid";
} }

View File

@@ -25,6 +25,13 @@ export type RehberOzet = {
tarih: string; 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 = { export type RehberSema = {
/** Mermaid tanımı (aria/altyazi yönergeleri ayıklanmış hâli) */ /** Mermaid tanımı (aria/altyazi yönergeleri ayıklanmış hâli) */
tanim: string; tanim: string;
@@ -40,6 +47,16 @@ export type RehberYazi = RehberOzet & { parcalar: RehberParca[] };
const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber"); 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): { function parseFrontmatter(raw: string): {
meta: Record<string, string>; meta: Record<string, string>;
body: string; body: string;
@@ -105,16 +122,28 @@ function parcala(slug: string, body: string): RehberParca[] {
return parcalar; return parcalar;
} }
function oku(slug: string): RehberYazi | null { function metaDogrula(
const dosya = path.join(REHBER_DIZIN, `${slug}.md`); slug: string,
if (!fs.existsSync(dosya)) return null; meta: Record<string, string>,
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8")); ): asserts meta is { baslik: string; aciklama: string; tarih: string } {
if (!meta.baslik || !meta.aciklama || !meta.tarih) { if (!meta.baslik || !meta.aciklama || !meta.tarih) {
throw new Error( throw new Error(
`rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`, `rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`,
); );
} }
return { }
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, slug,
baslik: meta.baslik, baslik: meta.baslik,
aciklama: meta.aciklama, aciklama: meta.aciklama,
@@ -122,6 +151,35 @@ function oku(slug: string): RehberYazi | null {
parcalar: parcala(slug, body), 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,
};
}
export function getAllRehberSlugs(): string[] { export function getAllRehberSlugs(): string[] {
if (!fs.existsSync(REHBER_DIZIN)) return []; if (!fs.existsSync(REHBER_DIZIN)) return [];
@@ -139,14 +197,22 @@ export function getRehber(slug: string): RehberYazi | null {
} }
export function getAllRehberler(): RehberOzet[] { export function getAllRehberler(): RehberOzet[] {
return getAllRehberSlugs() if (CACHE_AKTIF && globalRehber.rehberOzetler) {
.map((slug) => oku(slug)) return globalRehber.rehberOzetler;
.filter((y): y is RehberYazi => y !== null) }
.map(({ slug, baslik, aciklama, tarih }) => ({ 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, slug,
baslik, baslik,
aciklama, aciklama,
tarih, }));
}))
.sort((a, b) => b.tarih.localeCompare(a.tarih));
} }

View File

@@ -41,6 +41,20 @@ export type RankResults = {
toplam: Record<DilimKey, number>; 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, // Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer. // YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
export type UniturGrubu = "devlet" | "vakif"; export type UniturGrubu = "devlet" | "vakif";