Compare commits
7 Commits
d2732828cc
...
78d7c51a7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d7c51a7a | ||
|
|
f3d8bf9b94 | ||
|
|
668568b5b5 | ||
|
|
90ac590737 | ||
|
|
b7e10efd1d | ||
|
|
026debdc06 | ||
|
|
53edb2b307 |
@@ -22,12 +22,13 @@ const nextConfig: NextConfig = {
|
||||
"openai",
|
||||
"resend",
|
||||
],
|
||||
...(kaynakKisitli
|
||||
? {
|
||||
// cacheComponents açıkken prerender fazında source map üretimi varsayılan
|
||||
// açık; 1900+ statik sayfada ciddi bellek maliyeti var.
|
||||
enablePrerenderSourceMaps: false,
|
||||
experimental: {
|
||||
experimental: {
|
||||
// radix-ui kök barrel'ı 36 alt paketi çeker ve Next'in varsayılan
|
||||
// optimize listesinde yoktur; named import'lar doğrudan alt modüle
|
||||
// çevrilir (dev boot / build modül grafiği küçülür).
|
||||
optimizePackageImports: ["radix-ui"],
|
||||
...(kaynakKisitli
|
||||
? {
|
||||
// Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek
|
||||
// hedefi byte cinsinden ayrıca verilir (1.5 GB).
|
||||
turbopackMemoryLimit: 1_610_612_736,
|
||||
@@ -35,7 +36,14 @@ const nextConfig: NextConfig = {
|
||||
// worker × 4 sayfa. Her worker ayrı bir Node süreci/heap'idir.
|
||||
cpus: 1,
|
||||
staticGenerationMaxConcurrency: 4,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(kaynakKisitli
|
||||
? {
|
||||
// cacheComponents açıkken prerender fazında source map üretimi varsayılan
|
||||
// açık; 1900+ statik sayfada ciddi bellek maliyeti var.
|
||||
enablePrerenderSourceMaps: false,
|
||||
}
|
||||
: {}),
|
||||
async redirects() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { dilimAra } from "@/lib/db";
|
||||
import { programOzetle } from "@/features/liste/liste-queries";
|
||||
import {
|
||||
PUAN_TURLERI,
|
||||
type DilimKey,
|
||||
@@ -39,5 +40,6 @@ export function GET(request: NextRequest) {
|
||||
limit: SAYFA_BOYU,
|
||||
offset,
|
||||
});
|
||||
return NextResponse.json({ programlar, toplam });
|
||||
// Tablo satırının kullanmadığı alanlar payload'a girmesin
|
||||
return NextResponse.json({ programlar: programlar.map(programOzetle), toplam });
|
||||
}
|
||||
|
||||
@@ -106,17 +106,19 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession();
|
||||
// Oturum ve gövde ayrıştırma bağımsız — paralel başlar, hata önceliği aynı
|
||||
// kalır (önce 401, sonra 400).
|
||||
const [session, hamBody] = await Promise.all([
|
||||
getSession(),
|
||||
request.json().catch(() => null),
|
||||
]);
|
||||
if (!session) {
|
||||
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { message?: unknown; clientMessageId?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
if (hamBody === null || typeof hamBody !== "object") {
|
||||
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
|
||||
}
|
||||
const body = hamBody as { message?: unknown; clientMessageId?: unknown };
|
||||
const mesaj = typeof body.message === "string" ? body.message.trim() : "";
|
||||
const clientMessageId =
|
||||
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
|
||||
@@ -124,24 +126,30 @@ export async function POST(request: NextRequest) {
|
||||
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
|
||||
const rapor = await kullanicininRaporu(session.user.id);
|
||||
// Üç okuma da yalnızca session.user.id'ye bağlı — tek round trip'te paralel.
|
||||
// Geçmiş krediyi düşmeden ÖNCE okunur ki kullanıcı mesajı dahil olmasın;
|
||||
// hasPaket'i spendCreditForMessage değiştirmez, erken okumak güvenli.
|
||||
const [rapor, gecmisHam, kullanici] = await Promise.all([
|
||||
// Soru sormak için önce liste oluşturulmuş olmalı (bayat dev mock sayılmaz)
|
||||
kullanicininRaporu(session.user.id),
|
||||
appDb
|
||||
.select()
|
||||
.from(schema.chatMessages)
|
||||
.where(eq(schema.chatMessages.userId, session.user.id))
|
||||
.orderBy(desc(schema.chatMessages.createdAt))
|
||||
.limit(MAX_GECMIS),
|
||||
appDb.query.user.findFirst({
|
||||
where: eq(schema.user.id, session.user.id),
|
||||
columns: { hasPaket: true },
|
||||
}),
|
||||
]);
|
||||
if (!rapor?.result) {
|
||||
return Response.json(
|
||||
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Geçmişi krediyi düşmeden ÖNCE oku ki kullanıcı mesajı dahil olmasın
|
||||
const gecmis = (
|
||||
await appDb
|
||||
.select()
|
||||
.from(schema.chatMessages)
|
||||
.where(eq(schema.chatMessages.userId, session.user.id))
|
||||
.orderBy(desc(schema.chatMessages.createdAt))
|
||||
.limit(MAX_GECMIS)
|
||||
).reverse();
|
||||
const gecmis = gecmisHam.reverse();
|
||||
|
||||
// Kredi düşümü stream'den önce — yetersizse hiç başlama
|
||||
const harcama = await spendCreditForMessage({
|
||||
@@ -166,10 +174,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Paketsiz kullanıcının prompt'una yalnızca açık satırlar girer — aksi
|
||||
// halde danışman kilitli listeyi sözle sızdırabilir.
|
||||
const kullanici = await appDb.query.user.findFirst({
|
||||
where: eq(schema.user.id, session.user.id),
|
||||
columns: { hasPaket: true },
|
||||
});
|
||||
const kilitli = !kullanici?.hasPaket;
|
||||
const tamOzet = listeOzetiCikar(
|
||||
rapor.result as RaporSonuc,
|
||||
|
||||
@@ -194,6 +194,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Uzun tablo satırları: viewport dışı satırların layout/paint'i atlanır
|
||||
(rendering-content-visibility). contain-intrinsic-size satır yüksekliği
|
||||
tahminidir — scrollbar zıplamasını önler. Sorun çıkarsa satır bazında
|
||||
class kaldırılarak geri alınabilir. */
|
||||
@utility kt-cv-satir {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 57px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { KaydetBannerLazy } from "@/features/liste/components/kaydet-banner-lazy
|
||||
import { TercihProfiliKapisiLazy } from "@/features/liste/components/tercih-profili-kapisi-lazy";
|
||||
import { HeaderGate } from "@/components/header-gate";
|
||||
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
|
||||
import { DevPanel } from "@/components/dev/dev-panel";
|
||||
import { DevPanelMount } from "@/components/dev/dev-panel-mount";
|
||||
import { ScrollFlow } from "@/components/scroll-flow";
|
||||
import { JsonLd, organizationJsonLd, webSiteJsonLd, SITE_URL } from "@/lib/seo";
|
||||
import "./globals.css";
|
||||
@@ -85,19 +85,6 @@ export default function RootLayout({
|
||||
// içerikle büyümeli, viewport tabanı body'deki min-h-dvh'den gelir.
|
||||
className={`${calSans.variable} ${clarityCity.variable} ${geistMono.variable} ${bricolage.variable} antialiased`}
|
||||
>
|
||||
{/* Rybbit analitik — beforeInteractive her zaman <head> içine defer olarak
|
||||
enjekte edilir. Yalnızca prod build'de: Rybbit localhost'u da takip
|
||||
ettiği için dev trafiği prod verisini kirletmesin (DevPanel ile aynı
|
||||
kapı). mask-patterns: /sonuc URL'i ham başarı sıralamasını,
|
||||
/odeme/sonuc sipariş kimliğini taşıyor — path tek satırda toplanır. */}
|
||||
{process.env.NODE_ENV === "production" ? (
|
||||
<Script
|
||||
src="https://rybbit.kolaytercih.com/api/script.js"
|
||||
data-site-id="f2cb042c657e"
|
||||
data-mask-patterns='["/sonuc", "/odeme/sonuc"]'
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
) : null}
|
||||
<body className="min-h-dvh flex flex-col bg-slate-50">
|
||||
<HeaderGate>
|
||||
{/* Site geneli progressive blur — üst ve alt */}
|
||||
@@ -130,10 +117,23 @@ export default function RootLayout({
|
||||
{/* İlk "+" için sıra + sihirbaz kapısı */}
|
||||
<TercihProfiliKapisiLazy />
|
||||
<Toaster position="top-center" />
|
||||
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
|
||||
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}
|
||||
{/* Dev süperadmin paneli — prod build'de modül grafiğine bile girmez */}
|
||||
{process.env.NODE_ENV !== "production" ? <DevPanelMount /> : null}
|
||||
<JsonLd data={organizationJsonLd()} />
|
||||
<JsonLd data={webSiteJsonLd()} />
|
||||
{/* Rybbit analitik — afterInteractive: hydration sonrası client'ta
|
||||
enjekte edilir, LCP/kritik yolla yarışmaz. Yalnızca prod build'de:
|
||||
Rybbit localhost'u da takip ettiği için dev trafiği prod verisini
|
||||
kirletmesin (DevPanel ile aynı kapı). mask-patterns: /sonuc URL'i
|
||||
ham başarı sıralamasını, /odeme/sonuc sipariş kimliğini taşıyor. */}
|
||||
{process.env.NODE_ENV === "production" ? (
|
||||
<Script
|
||||
src="https://rybbit.kolaytercih.com/api/script.js"
|
||||
data-site-id="f2cb042c657e"
|
||||
data-mask-patterns='["/sonuc", "/odeme/sonuc"]'
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
) : null}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
18
src/components/dev/dev-panel-mount.tsx
Normal file
18
src/components/dev/dev-panel-mount.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePixelHeat } from "@/components/use-pixel-heat";
|
||||
|
||||
@@ -88,35 +88,41 @@ export function PixelField({
|
||||
|
||||
const COLS = 42;
|
||||
const ROWS = 12;
|
||||
const cx = (COLS - 1) / 2;
|
||||
const cy = (ROWS - 1) / 2;
|
||||
const maxD = Math.sqrt(cx * cx + cy * cy);
|
||||
|
||||
const pixels: ReactNode[] = [];
|
||||
let accentIdx = 0;
|
||||
// Çıktı seed'e göre deterministik; 504 iterasyon + ~150 element üretimini
|
||||
// her render'da (ör. çekmece reorder'ında) tekrarlama (rendering-hoist-jsx)
|
||||
const pixels = useMemo(() => {
|
||||
const cx = (COLS - 1) / 2;
|
||||
const cy = (ROWS - 1) / 2;
|
||||
const maxD = Math.sqrt(cx * cx + cy * cy);
|
||||
|
||||
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
|
||||
// (genişliğin %22–78 bandı) tüm yükseklik boyunca tamamen boş kalır
|
||||
const BAND_MIN = COLS * 0.22;
|
||||
const BAND_MAX = COLS * 0.78;
|
||||
const sonuc: ReactNode[] = [];
|
||||
let accentIdx = 0;
|
||||
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
if (x > BAND_MIN && x < BAND_MAX) continue;
|
||||
// Kenarlara doğru artan doluluk
|
||||
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
|
||||
const density = 0.1 + 0.42 * d * d;
|
||||
if (hash(x, y, seed) > density) continue;
|
||||
const accent = hash(x, y, seed + 99) > 0.96;
|
||||
pixels.push(
|
||||
pixelRect(x, y, {
|
||||
key: `${x}-${y}`,
|
||||
accent,
|
||||
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0,
|
||||
}),
|
||||
);
|
||||
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
|
||||
// (genişliğin %22–78 bandı) tüm yükseklik boyunca tamamen boş kalır
|
||||
const BAND_MIN = COLS * 0.22;
|
||||
const BAND_MAX = COLS * 0.78;
|
||||
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
if (x > BAND_MIN && x < BAND_MAX) continue;
|
||||
// Kenarlara doğru artan doluluk
|
||||
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
|
||||
const density = 0.1 + 0.42 * d * d;
|
||||
if (hash(x, y, seed) > density) continue;
|
||||
const accent = hash(x, y, seed + 99) > 0.96;
|
||||
sonuc.push(
|
||||
pixelRect(x, y, {
|
||||
key: `${x}-${y}`,
|
||||
accent,
|
||||
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sonuc;
|
||||
}, [seed]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -149,28 +155,32 @@ export function PixelDivider({
|
||||
|
||||
const COLS = 12;
|
||||
const ROWS = 2;
|
||||
const cx = (COLS - 1) / 2;
|
||||
|
||||
const pixels: ReactNode[] = [];
|
||||
let accentPlaced = false;
|
||||
// Deterministik çıktı — render başına yeniden üretme (PixelField ile aynı)
|
||||
const pixels = useMemo(() => {
|
||||
const cx = (COLS - 1) / 2;
|
||||
const sonuc: ReactNode[] = [];
|
||||
let accentPlaced = false;
|
||||
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
|
||||
const dx = Math.abs(x - cx) / cx;
|
||||
const rowFalloff = y === 0 ? 0.9 : 0.35;
|
||||
const density = (1 - dx) ** 1.1 * rowFalloff;
|
||||
if (hash(x, y, seed) > density) continue;
|
||||
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
|
||||
if (accent) accentPlaced = true;
|
||||
pixels.push(
|
||||
pixelRect(x, y, {
|
||||
key: `${x}-${y}`,
|
||||
accent,
|
||||
}),
|
||||
);
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
|
||||
const dx = Math.abs(x - cx) / cx;
|
||||
const rowFalloff = y === 0 ? 0.9 : 0.35;
|
||||
const density = (1 - dx) ** 1.1 * rowFalloff;
|
||||
if (hash(x, y, seed) > density) continue;
|
||||
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
|
||||
if (accent) accentPlaced = true;
|
||||
sonuc.push(
|
||||
pixelRect(x, y, {
|
||||
key: `${x}-${y}`,
|
||||
accent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sonuc;
|
||||
}, [seed]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -26,22 +26,32 @@ export function programEtkinSira(program: SiraSerisi): number | null {
|
||||
|
||||
/** 2021–2025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
|
||||
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
|
||||
const yillar = [
|
||||
// Satır başına render edilen bileşen: filter/map/min/max/map/map'in 6 ayrı
|
||||
// geçişi tek döngüde toplanır (js-combine-iterations, js-min-max-loop)
|
||||
const yillar: [number, number][] = [];
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
const ozetParcalari: string[] = [];
|
||||
for (const [yil, sira] of [
|
||||
[2021, program.sira2021],
|
||||
[2022, program.sira2022],
|
||||
[2023, program.sira2023],
|
||||
[2024, program.sira2024],
|
||||
[2025, program.sira2025],
|
||||
].filter((yil): yil is [number, number] => yil[1] != null);
|
||||
] as const) {
|
||||
if (sira == null) continue;
|
||||
yillar.push([yil, sira]);
|
||||
if (sira < min) min = sira;
|
||||
if (sira > max) max = sira;
|
||||
ozetParcalari.push(`${yil}: ~${sira.toLocaleString("tr-TR")}.`);
|
||||
}
|
||||
|
||||
if (yillar.length < 2) return <span className="text-slate-400">—</span>;
|
||||
|
||||
const siralar = yillar.map(([, sira]) => sira);
|
||||
const min = Math.min(...siralar);
|
||||
const max = Math.max(...siralar);
|
||||
const genislik = 56;
|
||||
const yukseklik = 18;
|
||||
const bosluk = 2;
|
||||
// min/max'a bağımlı olduğu için ikinci kısa döngü
|
||||
const noktalar = yillar.map(([yil, sira], index) => {
|
||||
const x =
|
||||
bosluk +
|
||||
@@ -54,9 +64,7 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
|
||||
return { x, y, yil, sira };
|
||||
});
|
||||
const son = noktalar[noktalar.length - 1];
|
||||
const ozet = yillar
|
||||
.map(([yil, sira]) => `${yil}: ~${sira.toLocaleString("tr-TR")}.`)
|
||||
.join("\n");
|
||||
const ozet = ozetParcalari.join("\n");
|
||||
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { KatalogArama } from "@/features/katalog/components/katalog-arama";
|
||||
import { UserNav, UserNavFallback } from "@/features/kullanici/components/user-nav";
|
||||
import { getAllRehberler } from "@/lib/rehber";
|
||||
import { getRehberAramaKayitlari } from "@/lib/rehber";
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
@@ -32,7 +32,7 @@ export function SiteHeader() {
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<KatalogArama rehberler={getAllRehberler()} />
|
||||
<KatalogArama rehberler={getRehberAramaKayitlari()} />
|
||||
{/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */}
|
||||
<Suspense fallback={<UserNavFallback />}>
|
||||
<UserNav />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
|
||||
import { CalendarDays, Clock3 } from "lucide-react";
|
||||
|
||||
@@ -47,7 +47,9 @@ export function SiteTopBanner() {
|
||||
const [geriSayim, setGeriSayim] = useState<GeriSayim | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const guncelle = () => setGeriSayim(geriSayimiHesapla(Date.now()));
|
||||
// Saniyelik tik acil değil — layout'ta her sayfada monte; urgent şeritten çık
|
||||
const guncelle = () =>
|
||||
startTransition(() => setGeriSayim(geriSayimiHesapla(Date.now())));
|
||||
|
||||
guncelle();
|
||||
const zamanlayici = window.setInterval(guncelle, 1_000);
|
||||
|
||||
32
src/components/ui/markdown-lazy.tsx
Normal file
32
src/components/ui/markdown-lazy.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { marked } from "marked"
|
||||
import { memo, useId, useMemo } from "react"
|
||||
import ReactMarkdown, { Components } from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
@@ -13,9 +12,27 @@ export type MarkdownProps = {
|
||||
components?: Partial<Components>
|
||||
}
|
||||
|
||||
// Stream memoization'ı için bloklara bölme. Eskiden marked.lexer yapıyordu —
|
||||
// react-markdown'ın micromark'ına ek ikinci bir tam parser'ı (~460 KB paket)
|
||||
// client'a taşımak blok bölmek için gereksiz: kod çiti dışındaki boş satırlar
|
||||
// zaten markdown'da blok sınırıdır. Çit içi boş satırlar korunur.
|
||||
function parseMarkdownIntoBlocks(markdown: string): string[] {
|
||||
const tokens = marked.lexer(markdown)
|
||||
return tokens.map((token) => token.raw)
|
||||
const bloklar: string[] = []
|
||||
let mevcut: string[] = []
|
||||
let citte = false
|
||||
for (const satir of markdown.split("\n")) {
|
||||
if (/^\s*(```|~~~)/.test(satir)) citte = !citte
|
||||
if (!citte && satir.trim() === "") {
|
||||
if (mevcut.length) {
|
||||
bloklar.push(mevcut.join("\n"))
|
||||
mevcut = []
|
||||
}
|
||||
} else {
|
||||
mevcut.push(satir)
|
||||
}
|
||||
}
|
||||
if (mevcut.length) bloklar.push(mevcut.join("\n"))
|
||||
return bloklar.length ? bloklar : [markdown]
|
||||
}
|
||||
|
||||
function extractLanguage(className?: string): string {
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Markdown } from "./markdown"
|
||||
import { MarkdownLazy } from "./markdown-lazy"
|
||||
import type { MarkdownProps } from "./markdown"
|
||||
|
||||
export type MessageProps = {
|
||||
children: React.ReactNode
|
||||
@@ -48,7 +49,7 @@ export type MessageContentProps = {
|
||||
children: React.ReactNode
|
||||
markdown?: boolean
|
||||
className?: string
|
||||
} & React.ComponentProps<typeof Markdown> &
|
||||
} & MarkdownProps &
|
||||
React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const MessageContent = ({
|
||||
@@ -63,9 +64,15 @@ const MessageContent = ({
|
||||
)
|
||||
|
||||
return markdown ? (
|
||||
<Markdown className={classNames} {...props}>
|
||||
// Fallback aynı class'lı düz metin: chunk inene dek CLS/flash olmadan
|
||||
// ham içerik görünür, sonra yerinde zenginleşir.
|
||||
<MarkdownLazy
|
||||
className={classNames}
|
||||
fallback={<div className={classNames}>{children}</div>}
|
||||
{...props}
|
||||
>
|
||||
{children as string}
|
||||
</Markdown>
|
||||
</MarkdownLazy>
|
||||
) : (
|
||||
<div className={classNames} {...props}>
|
||||
{children}
|
||||
|
||||
@@ -81,7 +81,6 @@ export function TypingAnimation({
|
||||
Component
|
||||
] as TypingAnimationMotionComponent
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>("")
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0)
|
||||
const [currentCharIndex, setCurrentCharIndex] = useState(0)
|
||||
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
|
||||
@@ -109,21 +108,32 @@ export function TypingAnimation({
|
||||
[words, children]
|
||||
)
|
||||
|
||||
// Kaynak metin değişince animasyon baştan başlar — bilinçli reset
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDisplayedText("")
|
||||
// Kaynak metin değişince animasyon baştan başlar — önceki-prop deseniyle
|
||||
// render sırasında (effect'te setState çift render yaratıyordu)
|
||||
const [oncekiKaynak, setOncekiKaynak] = useState(animationSourceKey)
|
||||
if (animationSourceKey !== oncekiKaynak) {
|
||||
setOncekiKaynak(animationSourceKey)
|
||||
setCurrentWordIndex(0)
|
||||
setCurrentCharIndex(0)
|
||||
setPhase("typing")
|
||||
}, [animationSourceKey])
|
||||
}
|
||||
|
||||
// Görünen metin state değil türetme: tick başına ekstra state yazımı ve
|
||||
// grapheme dizisi allocation'ı kalkar (rerender-derived-state-no-effect)
|
||||
const displayedText = useMemo(
|
||||
() =>
|
||||
Array.from(wordsToAnimate[currentWordIndex] || "")
|
||||
.slice(0, currentCharIndex)
|
||||
.join(""),
|
||||
[wordsToAnimate, currentWordIndex, currentCharIndex]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (!azMotion && shouldStart && wordsToAnimate.length > 0) {
|
||||
const timeoutDelay =
|
||||
delay > 0 && displayedText === ""
|
||||
delay > 0 && currentCharIndex === 0
|
||||
? delay
|
||||
: phase === "typing"
|
||||
? typingSpeed
|
||||
@@ -138,9 +148,6 @@ export function TypingAnimation({
|
||||
switch (phase) {
|
||||
case "typing":
|
||||
if (currentCharIndex < graphemes.length) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex + 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex + 1)
|
||||
} else {
|
||||
if (hasMultipleWords || loop) {
|
||||
@@ -159,9 +166,6 @@ export function TypingAnimation({
|
||||
|
||||
case "deleting":
|
||||
if (currentCharIndex > 0) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex - 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex - 1)
|
||||
} else {
|
||||
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
|
||||
@@ -184,7 +188,6 @@ export function TypingAnimation({
|
||||
phase,
|
||||
currentCharIndex,
|
||||
currentWordIndex,
|
||||
displayedText,
|
||||
wordsToAnimate,
|
||||
hasMultipleWords,
|
||||
loop,
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
*/
|
||||
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { animate, type AnimationPlaybackControls } from "motion";
|
||||
// WAAPI tabanlı mini animate yeterli (yalnızca duration/ease kullanılıyor);
|
||||
// tam motor client bundle'ına girmez. Tip mini'den export edilmiyor —
|
||||
// type-only import build'de silinir, maliyeti yok.
|
||||
import { animate } from "motion/mini";
|
||||
import type { AnimationPlaybackControls } from "motion";
|
||||
|
||||
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
|
||||
const HOT_COLOR = "#f97316"; // orange-500
|
||||
|
||||
@@ -34,15 +34,26 @@ function KategoriGruplari({
|
||||
bolumler: BolumOzet[];
|
||||
seviye: "lisans" | "onlisans";
|
||||
}) {
|
||||
const kalanlar = new Set(bolumler.map((b) => b.slug));
|
||||
const gruplar = KATEGORILER.map((kategori) => {
|
||||
const uyeler = bolumler.filter((b) =>
|
||||
(b.kategoriler as readonly string[]).includes(kategori),
|
||||
);
|
||||
for (const b of uyeler) kalanlar.delete(b.slug);
|
||||
return { kategori: kategori as string, uyeler };
|
||||
}).filter((g) => g.uyeler.length > 0);
|
||||
const diger = bolumler.filter((b) => kalanlar.has(b.slug));
|
||||
// Tek geçişte kategori index'i — kategori×bölüm çapraz filter yerine.
|
||||
// Bir bölüm birden çok kategoride görünebilir; hiçbirinde yoksa "Diğer"e düşer.
|
||||
const uyeMap = new Map<string, BolumOzet[]>();
|
||||
const diger: BolumOzet[] = [];
|
||||
const gecerli = new Set<string>(KATEGORILER);
|
||||
for (const b of bolumler) {
|
||||
let eslesti = false;
|
||||
for (const kategori of b.kategoriler as readonly string[]) {
|
||||
if (!gecerli.has(kategori)) continue;
|
||||
const grup = uyeMap.get(kategori);
|
||||
if (grup) grup.push(b);
|
||||
else uyeMap.set(kategori, [b]);
|
||||
eslesti = true;
|
||||
}
|
||||
if (!eslesti) diger.push(b);
|
||||
}
|
||||
const gruplar = KATEGORILER.filter((k) => uyeMap.has(k)).map((kategori) => ({
|
||||
kategori: kategori as string,
|
||||
uyeler: uyeMap.get(kategori)!,
|
||||
}));
|
||||
if (diger.length > 0) gruplar.push({ kategori: "Diğer", uyeler: diger });
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { startTransition, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
@@ -104,19 +104,17 @@ export function BolumProgramListesi({
|
||||
const siraliProgramlar = useMemo(() => {
|
||||
if (!aktifAlan) return programlar;
|
||||
|
||||
return programlar
|
||||
.map((program, index) => ({ program, index }))
|
||||
.sort((a, b) => {
|
||||
const aDeger = a.program[aktifAlan];
|
||||
const bDeger = b.program[aktifAlan];
|
||||
if (aDeger == null && bDeger == null) return a.index - b.index;
|
||||
if (aDeger == null) return 1;
|
||||
if (bDeger == null) return -1;
|
||||
|
||||
const fark = aDeger - bDeger;
|
||||
return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark;
|
||||
})
|
||||
.map(({ program }) => program);
|
||||
// toSorted stable: eşitlikte orijinal sıra zaten korunur, index
|
||||
// dekorasyonu (N obje allocation + 2 ek geçiş) gereksizdi.
|
||||
return programlar.toSorted((a, b) => {
|
||||
const aDeger = a[aktifAlan];
|
||||
const bDeger = b[aktifAlan];
|
||||
if (aDeger == null && bDeger == null) return 0;
|
||||
if (aDeger == null) return 1;
|
||||
if (bDeger == null) return -1;
|
||||
const fark = aDeger - bDeger;
|
||||
return yon === "artan" ? fark : -fark;
|
||||
});
|
||||
}, [aktifAlan, programlar, yon]);
|
||||
|
||||
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
|
||||
@@ -129,18 +127,22 @@ export function BolumProgramListesi({
|
||||
[siraliProgramlar, gosterilenSayfa],
|
||||
);
|
||||
|
||||
// Yüzlerce satırın sort+render'ı tıklamayı bloklamasın; buton geri
|
||||
// bildirimi CSS'ten geldiği için tüm güncelleme non-urgent kalabilir.
|
||||
function sirala(alan: SiralamaAlani) {
|
||||
setIstemciSayfa(1);
|
||||
if (aktifAlan === alan) {
|
||||
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
||||
return;
|
||||
}
|
||||
setAktifAlan(alan);
|
||||
setYon("artan");
|
||||
startTransition(() => {
|
||||
setIstemciSayfa(1);
|
||||
if (aktifAlan === alan) {
|
||||
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
||||
return;
|
||||
}
|
||||
setAktifAlan(alan);
|
||||
setYon("artan");
|
||||
});
|
||||
}
|
||||
|
||||
function istemciSayfaDegistir(n: number) {
|
||||
setIstemciSayfa(n);
|
||||
startTransition(() => setIstemciSayfa(n));
|
||||
listeRef.current?.scrollIntoView({ block: "start" });
|
||||
}
|
||||
|
||||
@@ -193,7 +195,7 @@ export function BolumProgramListesi({
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableRow key={p.id} className="kt-cv-satir">
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{p.universiteSlug ? (
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AramaFunnelKarti } from "@/components/arama-funnel-karti";
|
||||
import { RehberKapak } from "@/features/rehber/components/rehber-kapak";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
|
||||
import type { RehberOzet } from "@/lib/rehber";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import type { RehberAramaKaydi } from "@/lib/rehber";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
sonucaGitIste,
|
||||
useTercihProfili,
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { preloadTercihProfiliKapisi } from "@/features/liste/components/tercih-profili-kapisi-lazy";
|
||||
import { PUAN_TURU_ETIKET } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
@@ -60,6 +71,18 @@ const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
|
||||
const REHBER_SONUC_SINIRI = 4;
|
||||
const sayi = (deger: number) => deger.toLocaleString("tr-TR");
|
||||
|
||||
// Platform değişmez — abonelik gerektirmeyen dış değer (useSyncExternalStore)
|
||||
const bosAbonelik = () => () => {};
|
||||
const macMiSunucu = () => true;
|
||||
function macMiOku(): boolean {
|
||||
const platform =
|
||||
(navigator as { userAgentData?: { platform?: string } }).userAgentData
|
||||
?.platform ??
|
||||
navigator.platform ??
|
||||
"";
|
||||
return /mac|iphone|ipad/i.test(platform);
|
||||
}
|
||||
|
||||
const KESFET_TONLARI = {
|
||||
slate: {
|
||||
pencere: "border-slate-200/80 bg-white",
|
||||
@@ -81,7 +104,7 @@ const KESFET_TONLARI = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) {
|
||||
const router = useRouter();
|
||||
const profil = useTercihProfili();
|
||||
const [acik, setAcik] = useState(false);
|
||||
@@ -94,8 +117,13 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
}
|
||||
const [sorgu, setSorgu] = useState("");
|
||||
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
|
||||
const [yukleniyor, setYukleniyor] = useState(false);
|
||||
const [macMi, setMacMi] = useState(true);
|
||||
// Yükleniyor ayrı state değil türetme: "yazılan sorgu henüz cevaplanmadı".
|
||||
// onChange'de elle iskelet açıp fetch finally'sinde kapatma dansı kalktı.
|
||||
const [cevaplananSorgu, setCevaplananSorgu] = useState("");
|
||||
// Kısayol etiketi (⌘/Ctrl) platforma bağlı sabit bir dış değer: effect'te
|
||||
// setState yerine useSyncExternalStore — SSR'da ⌘ (eski varsayılan),
|
||||
// hydration sonrası gerçek platform.
|
||||
const macMi = useSyncExternalStore(bosAbonelik, macMiOku, macMiSunucu);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const kaydirmaRef = useRef<HTMLDivElement>(null);
|
||||
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
|
||||
@@ -106,6 +134,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
} | null>(null);
|
||||
const temizSorgu = sorgu.trim();
|
||||
const aramaAktif = temizSorgu.length >= 2;
|
||||
const yukleniyor = aramaAktif && cevaplananSorgu !== temizSorgu;
|
||||
|
||||
const kenarBlurGuncelle = useCallback(() => {
|
||||
const alan = kaydirmaRef.current;
|
||||
@@ -122,16 +151,16 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
const rehberSonuclari = useMemo(() => {
|
||||
if (!aramaAktif) return [];
|
||||
const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80);
|
||||
// Puan öğe başına BİR kez hesaplanır (Schwartzian) — sort comparator'ı
|
||||
// her karşılaştırmada eslesmePuani->aramaNormalize çalıştırıyordu
|
||||
return rehberler
|
||||
.filter((yazi) =>
|
||||
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
eslesmePuani(a.baslik, normalSorgu) -
|
||||
eslesmePuani(b.baslik, normalSorgu),
|
||||
)
|
||||
.slice(0, REHBER_SONUC_SINIRI);
|
||||
.map((yazi) => ({ yazi, puan: eslesmePuani(yazi.baslik, normalSorgu) }))
|
||||
.sort((a, b) => a.puan - b.puan)
|
||||
.slice(0, REHBER_SONUC_SINIRI)
|
||||
.map(({ yazi }) => yazi);
|
||||
}, [aramaAktif, temizSorgu, rehberler]);
|
||||
|
||||
const sonucVar =
|
||||
@@ -139,40 +168,39 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
sonuclar.universiteler.length > 0 ||
|
||||
rehberSonuclari.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const platform =
|
||||
(navigator as { userAgentData?: { platform?: string } }).userAgentData
|
||||
?.platform ??
|
||||
navigator.platform ??
|
||||
"";
|
||||
setMacMi(/mac|iphone|ipad/i.test(platform));
|
||||
}, []);
|
||||
|
||||
// Listener bir kez bağlanır; güncel `acik` useEffectEvent içinden okunur —
|
||||
// modal her açılıp kapandığında remove/add döngüsü dönmesin
|
||||
// (client-event-listeners)
|
||||
const kisayolTetiklendi = useEffectEvent(() => modalDegisti(!acik));
|
||||
useEffect(() => {
|
||||
function kisayolDinle(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
modalDegisti(!acik);
|
||||
kisayolTetiklendi();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", kisayolDinle);
|
||||
return () => window.removeEventListener("keydown", kisayolDinle);
|
||||
}, [acik]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!acik || !aramaAktif) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const zamanlayici = window.setTimeout(async () => {
|
||||
setYukleniyor(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
// Dedup+TTL cache: aynı sorgu ("psikoloji" yazıp silip tekrar yazan
|
||||
// kullanıcı) ağa çıkmadan bellekten döner. İptal yalnızca bu
|
||||
// bekleyişi düşürür; paylaşılan fetch sürer.
|
||||
const veri = await onbellekliJsonGetir<AramaSonuclari>(
|
||||
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!response.ok) throw new Error("Arama başarısız");
|
||||
const veri = (await response.json()) as AramaSonuclari;
|
||||
setSonuclar(veri);
|
||||
// Sonuç listesi acil değil — input yazımıyla yarışmasın
|
||||
startTransition(() => {
|
||||
setSonuclar(veri);
|
||||
setCevaplananSorgu(temizSorgu);
|
||||
});
|
||||
// Olay burada değil kapanışta gönderilir; fetch her tuş vuruşunda
|
||||
// (160ms debounce) tetikleniyor, aramayı oturum başına bir kez sayarız.
|
||||
sonAramaRef.current = {
|
||||
@@ -181,10 +209,11 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
};
|
||||
} catch (hata) {
|
||||
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
startTransition(() => {
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
setCevaplananSorgu(temizSorgu);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setYukleniyor(false);
|
||||
}
|
||||
}, 160);
|
||||
|
||||
@@ -217,7 +246,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
if (yeniAcik) {
|
||||
setSorgu("");
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
setYukleniyor(false);
|
||||
setCevaplananSorgu("");
|
||||
setKenarBlur({ ust: false, alt: false });
|
||||
}
|
||||
}
|
||||
@@ -243,6 +272,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
|
||||
className="group/profil flex cursor-pointer items-center gap-2 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-150 hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none"
|
||||
>
|
||||
@@ -269,6 +300,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={siralamaGirVeyaSonucaGit}
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full px-4 py-1.5 text-slate-700 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Trophy className="size-4 text-orange-500" aria-hidden />
|
||||
@@ -304,6 +337,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
aria-label={`Sıralamanı ve tercihlerini değiştir (${profil.sira.toLocaleString("tr-TR")} · ${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur})`}
|
||||
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-1.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-2 sm:text-sm"
|
||||
>
|
||||
@@ -329,6 +364,8 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={siralamaGirVeyaSonucaGit}
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
aria-label="Başarı sıralamanı gir"
|
||||
className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-2.5 text-xs font-semibold text-slate-900 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:h-12 sm:gap-2 sm:pl-3.5 sm:pr-3 sm:text-sm"
|
||||
>
|
||||
@@ -375,12 +412,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={sorgu}
|
||||
onChange={(event) => {
|
||||
const yeniSorgu = event.target.value;
|
||||
setSorgu(yeniSorgu);
|
||||
setSonuclar(BOS_SONUCLAR);
|
||||
setYukleniyor(yeniSorgu.trim().length >= 2);
|
||||
}}
|
||||
onChange={(event) => setSorgu(event.target.value)}
|
||||
placeholder="Bölüm, üniversite veya rehber ara..."
|
||||
aria-label="Bölüm, üniversite veya rehber ara"
|
||||
autoComplete="off"
|
||||
@@ -722,7 +754,7 @@ function RehberSonucKarti({
|
||||
yazi,
|
||||
onSec,
|
||||
}: {
|
||||
yazi: RehberOzet;
|
||||
yazi: RehberAramaKaydi;
|
||||
onSec: () => void;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -36,13 +36,15 @@ function UniLinkleri({ uniler }: { uniler: UniOzet[] }) {
|
||||
|
||||
export function UniversiteKatalogu() {
|
||||
const hepsi = getAllUniversiteler();
|
||||
const devlet = hepsi.filter((u) => u.unitur === "DEVLET");
|
||||
const vakif = hepsi.filter(
|
||||
(u) => u.unitur != null && ["VAKIF", "VAKIF MYO"].includes(u.unitur),
|
||||
);
|
||||
const digerUniler = hepsi.filter(
|
||||
(u) => !devlet.includes(u) && !vakif.includes(u),
|
||||
);
|
||||
// Tek geçişte üç grup — filter×3 + includes'lı O(n²) tarama yerine
|
||||
const devlet: UniOzet[] = [];
|
||||
const vakif: UniOzet[] = [];
|
||||
const digerUniler: UniOzet[] = [];
|
||||
for (const u of hepsi) {
|
||||
if (u.unitur === "DEVLET") devlet.push(u);
|
||||
else if (u.unitur === "VAKIF" || u.unitur === "VAKIF MYO") vakif.push(u);
|
||||
else digerUniler.push(u);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function UniversiteProgramTablosu({
|
||||
{programlar.map((p) => {
|
||||
const taban = programEtkinSira(p);
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableRow key={p.id} className="kt-cv-satir">
|
||||
<TableCell className="max-w-0 w-full">
|
||||
{p.bolumSlug ? (
|
||||
<Link
|
||||
|
||||
@@ -2,10 +2,22 @@
|
||||
|
||||
// Banner yalnızca girişsiz + 3 seçimli kullanıcıda görünür; baseline
|
||||
// bundle'a girmesin diye ssr'siz dynamic import (liste-cekmecesi-lazy deseni).
|
||||
// Kapı: liste eşiğin altındayken chunk hiç inmez. Giriş/pathname koşulları
|
||||
// bileşenin kendisinde kalır — burada yalnızca en ayırt edici koşul var.
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useManuelListe } from "../liste-store";
|
||||
|
||||
export const KaydetBannerLazy = dynamic(
|
||||
// kaydet-banner.tsx'teki ESIK ile aynı değer; oradan import etmek statik
|
||||
// bağımlılıkla chunk'ı geri çekerdi.
|
||||
const ESIK = 3;
|
||||
|
||||
const KaydetBannerIc = dynamic(
|
||||
() => import("./kaydet-banner").then((m) => m.KaydetBanner),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export function KaydetBannerLazy() {
|
||||
const liste = useManuelListe();
|
||||
return liste.length >= ESIK ? <KaydetBannerIc /> : null;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,27 @@
|
||||
|
||||
// Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline
|
||||
// bundle'a girmemesi için ssr'siz dynamic import (layout server component
|
||||
// olduğundan bu sarmalayıcı gerekli).
|
||||
// olduğundan bu sarmalayıcı gerekli). Kapı: liste boş ve çekmece kapalıyken
|
||||
// chunk hiç inmez — hydration'la birlikte koşulsuz inmesin (bundle-conditional).
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCekmeceAcik, useManuelListe } from "../liste-store";
|
||||
|
||||
export const ListeCekmecesiLazy = dynamic(
|
||||
const ListeCekmecesiIc = dynamic(
|
||||
() => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/** Navbar butonu hover/focus'unda chunk'ı önden ısıt (bundle-preload). */
|
||||
export function preloadListeCekmecesi() {
|
||||
void import("./liste-cekmecesi");
|
||||
}
|
||||
|
||||
export function ListeCekmecesiLazy() {
|
||||
const acik = useCekmeceAcik();
|
||||
const liste = useManuelListe();
|
||||
// "+" uçuş animasyon katmanı da çekmece modülünde yaşadığı için liste
|
||||
// doluysa (çekmece kapalı olsa da) mount kalır; yalnızca boş+kapalı durumda
|
||||
// atlanır — ilk ziyaretin en yaygın hâli.
|
||||
return acik || liste.length > 0 ? <ListeCekmecesiIc /> : null;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useCekmeceAcik,
|
||||
useManuelListe,
|
||||
} from "../liste-store";
|
||||
import { preloadListeCekmecesi } from "./liste-cekmecesi-lazy";
|
||||
|
||||
export function ListemButonu() {
|
||||
const liste = useManuelListe();
|
||||
@@ -26,6 +27,8 @@ export function ListemButonu() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={cekmeceDegistir}
|
||||
onPointerEnter={preloadListeCekmecesi}
|
||||
onFocus={preloadListeCekmecesi}
|
||||
aria-expanded={acik}
|
||||
aria-label={`Kendi listem — ${liste.length}/${MANUEL_LISTE_MAX} program`}
|
||||
title="Tablodan seçtiğin programlar"
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/features/liste/components/tercih-haritasi";
|
||||
import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
|
||||
import type { HaritaPin } from "@/features/liste/components/tercih-haritasi";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
import { useManuelListe } from "../liste-store";
|
||||
@@ -53,9 +54,24 @@ export function ManuelHarita() {
|
||||
return { pinler: [...gruplar.values()], haritaDisi };
|
||||
}, [liste]);
|
||||
|
||||
// Liste boşken 197 KB'lık il geometrisi hiç inmesin: aynı oranlı
|
||||
// placeholder alanı rezerve eder, ilk eklemede lazy harita yerine oturur.
|
||||
if (liste.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex aspect-[1050/447] w-full items-center justify-center rounded-xl bg-slate-100/60">
|
||||
<p className="max-w-xs px-4 text-center text-xs text-slate-500">
|
||||
Tablodan <span aria-hidden>+</span> ile ekledikçe üniversiteler
|
||||
haritada belirir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TercihHaritasi
|
||||
<TercihHaritasiLazy
|
||||
pinler={pinler}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
@@ -65,12 +81,6 @@ export function ManuelHarita() {
|
||||
{haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
{liste.length === 0 ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500">
|
||||
Tablodan <span aria-hidden>+</span> ile ekledikçe üniversiteler
|
||||
haritada belirir.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import { toast } from "sonner";
|
||||
import type { Program } from "@/types/yokatlas";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import {
|
||||
listedeMi,
|
||||
MANUEL_LISTE_MAX,
|
||||
useManuelListe,
|
||||
useListeDoluMu,
|
||||
useListedeMi,
|
||||
type ManuelKaynak,
|
||||
} from "../liste-store";
|
||||
import { programEkleIste } from "../hooks/use-tercih-profili";
|
||||
import { preloadTercihProfiliKapisi } from "./tercih-profili-kapisi-lazy";
|
||||
|
||||
type ProgramOzet = Pick<
|
||||
Program,
|
||||
@@ -37,9 +38,11 @@ export function ProgramEkleButonu({
|
||||
adayTur?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const liste = useManuelListe();
|
||||
const listede = liste.some((t) => t.id === program.id) || listedeMi(program.id);
|
||||
const doldu = liste.length >= MANUEL_LISTE_MAX;
|
||||
// Primitive snapshot'lı selector'lar: liste mutasyonunda yalnız üyeliği
|
||||
// değişen buton render olur (eskiden 50 satır × tam liste aboneliği + aynı
|
||||
// taramanın listedeMi ile ikinci kez yapılması vardı).
|
||||
const listede = useListedeMi(program.id);
|
||||
const doldu = useListeDoluMu();
|
||||
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
@@ -59,6 +62,9 @@ export function ProgramEkleButonu({
|
||||
type="button"
|
||||
disabled={listede || doldu}
|
||||
onClick={handleClick}
|
||||
// Profilsiz kullanıcıda + kapıyı açar — chunk'ı niyet anında ısıt
|
||||
onPointerEnter={preloadTercihProfiliKapisi}
|
||||
onFocus={preloadTercihProfiliKapisi}
|
||||
title={
|
||||
listede
|
||||
? "Zaten seçtiklerinde"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// sayfalanır ("daha fazla göster" /api/programlar'dan akıtır). Her satırın "+"
|
||||
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { DilimKey, Program, PuanTuruKey, RankResults } from "@/types/yokatlas";
|
||||
import type {
|
||||
DilimKey,
|
||||
ProgramListeSatiri,
|
||||
PuanTuruKey,
|
||||
RankResultsListe,
|
||||
} from "@/types/yokatlas";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||
import {
|
||||
@@ -31,6 +36,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ProgramEkleButonu } from "@/features/liste/components/program-ekle-butonu";
|
||||
import { preloadTercihHaritasi } from "@/features/liste/components/tercih-haritasi-lazy";
|
||||
import { ProgramNetleriButonu } from "@/features/katalog/components/program-netleri";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
|
||||
@@ -70,7 +76,7 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
|
||||
riskli: "bg-red-500",
|
||||
};
|
||||
|
||||
const BOS_EKSTRA: Record<DilimKey, Program[]> = {
|
||||
const BOS_EKSTRA: Record<DilimKey, ProgramListeSatiri[]> = {
|
||||
hayal: [],
|
||||
dengeli: [],
|
||||
garanti: [],
|
||||
@@ -78,7 +84,7 @@ const BOS_EKSTRA: Record<DilimKey, Program[]> = {
|
||||
|
||||
// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir
|
||||
// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır.
|
||||
function varsayilanDilim(sonuclar: RankResults): DilimKey {
|
||||
function varsayilanDilim(sonuclar: RankResultsListe): DilimKey {
|
||||
return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0
|
||||
? "garanti"
|
||||
: "dengeli";
|
||||
@@ -89,7 +95,7 @@ export function ProgramTablosu({
|
||||
adaySira,
|
||||
tur,
|
||||
}: {
|
||||
sonuclar: RankResults;
|
||||
sonuclar: RankResultsListe;
|
||||
adaySira: number;
|
||||
tur: PuanTuruKey;
|
||||
}) {
|
||||
@@ -113,6 +119,13 @@ export function ProgramTablosu({
|
||||
}
|
||||
}
|
||||
|
||||
// Görünmeyen sekmeler dahil 3 dilimin spread'i her render'da tekrarlanmasın
|
||||
const tumSatirlar = useMemo(() => {
|
||||
const r = {} as Record<DilimKey, ProgramListeSatiri[]>;
|
||||
for (const d of DILIMLER) r[d.key] = [...sonuclar[d.key], ...ekstra[d.key]];
|
||||
return r;
|
||||
}, [sonuclar, ekstra]);
|
||||
|
||||
async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
|
||||
setYuklenen(dilim);
|
||||
try {
|
||||
@@ -120,7 +133,7 @@ export function ProgramTablosu({
|
||||
`/api/programlar?sira=${adaySira}&tur=${tur}&dilim=${dilim}&offset=${offset}`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const veri = (await res.json()) as { programlar: Program[] };
|
||||
const veri = (await res.json()) as { programlar: ProgramListeSatiri[] };
|
||||
// Aynı sayfanın iki kez istenmesine karşı id bazında tekilleştir
|
||||
setEkstra((mevcut) => {
|
||||
const eldekiler = new Set(
|
||||
@@ -141,7 +154,14 @@ export function ProgramTablosu({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
|
||||
<section
|
||||
className="mt-16"
|
||||
aria-label="Sıralamanla açılan programlar"
|
||||
// Tabloya niyet gösteren kullanıcı büyük ihtimalle + ile harita
|
||||
// çizdirecek — chunk'ı görünmeden ısıt (bundle-preload)
|
||||
onPointerEnter={preloadTercihHaritasi}
|
||||
onFocusCapture={preloadTercihHaritasi}
|
||||
>
|
||||
<Tabs
|
||||
value={aktifDilim}
|
||||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||||
@@ -161,7 +181,7 @@ export function ProgramTablosu({
|
||||
</TabsList>
|
||||
|
||||
{DILIMLER.map((d) => {
|
||||
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]];
|
||||
const satirlar = tumSatirlar[d.key];
|
||||
const toplam = sonuclar.toplam[d.key];
|
||||
return (
|
||||
<TabsContent key={d.key} value={d.key}>
|
||||
@@ -232,7 +252,7 @@ function ProgramSatiri({
|
||||
adaySira,
|
||||
tur,
|
||||
}: {
|
||||
program: Program;
|
||||
program: ProgramListeSatiri;
|
||||
adaySira: number;
|
||||
tur: PuanTuruKey;
|
||||
}) {
|
||||
@@ -241,7 +261,7 @@ function ProgramSatiri({
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableRow className="kt-cv-satir">
|
||||
<TableCell className="pl-4 pr-0">
|
||||
{risk ? (
|
||||
<span
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
sonucaGitIste,
|
||||
useTercihProfili,
|
||||
} from "../hooks/use-tercih-profili";
|
||||
import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
|
||||
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
|
||||
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
|
||||
@@ -176,7 +178,7 @@ export function SecimlerimPaneli() {
|
||||
|
||||
<ol className="flex flex-col gap-3">
|
||||
{liste.map((t, i) => (
|
||||
<SecimSatiri key={t.id} tercih={t} sira={i + 1} />
|
||||
<SecimSatiri key={t.id} tercih={t} sira={i + 1} profil={profil} />
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
@@ -196,14 +198,17 @@ function Istatistik({ etiket, deger }: { etiket: string; deger: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SecimSatiri({
|
||||
// Profil parent'tan prop olarak gelir (parent zaten abone) + memo: 24 satırın
|
||||
// her biri ayrı store aboneliği kurup her profil olayında uyanıyordu.
|
||||
const SecimSatiri = memo(function SecimSatiri({
|
||||
tercih,
|
||||
sira,
|
||||
profil,
|
||||
}: {
|
||||
tercih: ManuelTercih;
|
||||
sira: number;
|
||||
profil: TercihProfili | null;
|
||||
}) {
|
||||
const profil = useTercihProfili();
|
||||
const degerlendirme = profil
|
||||
? tercihDegerlendir(
|
||||
{
|
||||
@@ -302,4 +307,4 @@ function SecimSatiri({
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
27
src/features/liste/components/tercih-haritasi-lazy.tsx
Normal file
27
src/features/liste/components/tercih-haritasi-lazy.tsx
Normal 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");
|
||||
}
|
||||
@@ -57,6 +57,12 @@ const TAM_GORUNUM = {
|
||||
|
||||
type ViewBox = typeof TAM_GORUNUM;
|
||||
|
||||
// normalizeIlAdi il başına modül yükünde bir kez — render başına 81 çağrı yerine
|
||||
const NORMAL_SEHIRLER = cities.map((city) => ({
|
||||
city,
|
||||
key: normalizeIlAdi(city.name),
|
||||
}));
|
||||
|
||||
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
|
||||
function kisaUniAdi(ad: string): string {
|
||||
const kisa = ad
|
||||
@@ -122,18 +128,54 @@ export function TercihHaritasi({
|
||||
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
|
||||
onTercihSec?: (sira: number) => void;
|
||||
}) {
|
||||
const pathRefs = useRef(new Map<string, SVGPathElement>());
|
||||
// 81 il path'inin inline ref callback'i her render'da yeniden üretilmesin
|
||||
// (React her render'da 81 detach/attach yapıyordu): key başına stabil
|
||||
// callback bir kez kurulur (il listesi statik). Ref yerine lazy-state
|
||||
// closure'ı — callback'ler commit anında koşar, render'da ref okunmaz.
|
||||
const [pathKayit] = useState(() => {
|
||||
const elemanlar = new Map<string, SVGPathElement>();
|
||||
const callbacks = new Map<string, (el: SVGPathElement | null) => void>();
|
||||
for (const { key } of NORMAL_SEHIRLER) {
|
||||
callbacks.set(key, (el) => {
|
||||
if (el) elemanlar.set(key, el);
|
||||
else elemanlar.delete(key);
|
||||
});
|
||||
}
|
||||
return { elemanlar, callbacks };
|
||||
});
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
// vb yalnızca sf (zoom oran çarpanı) için state; viewBox akışı TRANSIENT —
|
||||
// kare başına setState 380ms boyunca tüm SVG ağacını (~23 render × 81 path)
|
||||
// yeniden kuruyordu. Artık animasyon svg.setAttribute ile ref üzerinden
|
||||
// akar, React yalnızca animasyon başında bir kez render eder.
|
||||
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
|
||||
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
|
||||
const animRef = useRef(0);
|
||||
|
||||
// viewBox JSX'te değil: React'in commit'i animasyon karesini ezmesin.
|
||||
// İlk değer mount'ta bir kez yazılır (stabil callback → yalnızca mount'ta).
|
||||
const svgMount = useCallback((el: SVGSVGElement | null) => {
|
||||
svgRef.current = el;
|
||||
if (el) {
|
||||
const v = vbRef.current;
|
||||
el.setAttribute("viewBox", `${v.x} ${v.y} ${v.w} ${v.h}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animateTo = useCallback((hedef: ViewBox) => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
const kaynak = { ...vbRef.current };
|
||||
const t0 = performance.now();
|
||||
const SURE = 380;
|
||||
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
let ilkKare = true;
|
||||
const adim = (now: number) => {
|
||||
if (ilkKare) {
|
||||
ilkKare = false;
|
||||
// sf'e bağlı ölçüler tek render'da nihai değere oturur; rAF içinde
|
||||
// (asenkron) çağrıldığı için effect'te senkron setState sayılmaz.
|
||||
setVb(hedef);
|
||||
}
|
||||
const t = Math.min(1, (now - t0) / SURE);
|
||||
const k = ease(t);
|
||||
const sonraki = {
|
||||
@@ -143,7 +185,10 @@ export function TercihHaritasi({
|
||||
h: kaynak.h + (hedef.h - kaynak.h) * k,
|
||||
};
|
||||
vbRef.current = sonraki;
|
||||
setVb(sonraki);
|
||||
svgRef.current?.setAttribute(
|
||||
"viewBox",
|
||||
`${sonraki.x} ${sonraki.y} ${sonraki.w} ${sonraki.h}`,
|
||||
);
|
||||
if (t < 1) animRef.current = requestAnimationFrame(adim);
|
||||
};
|
||||
animRef.current = requestAnimationFrame(adim);
|
||||
@@ -157,7 +202,7 @@ export function TercihHaritasi({
|
||||
animateTo(TAM_GORUNUM);
|
||||
return;
|
||||
}
|
||||
const path = pathRefs.current.get(seciliIl);
|
||||
const path = pathKayit.elemanlar.get(seciliIl);
|
||||
if (!path) return;
|
||||
const b = path.getBBox();
|
||||
const pad = Math.max(b.width, b.height) * 0.22;
|
||||
@@ -178,7 +223,7 @@ export function TercihHaritasi({
|
||||
w = yeniW;
|
||||
}
|
||||
animateTo({ x, y, w, h });
|
||||
}, [seciliIl, animateTo]);
|
||||
}, [seciliIl, animateTo, pathKayit.elemanlar]);
|
||||
|
||||
const ilPinleri = useMemo(() => {
|
||||
const gruplar = new Map<string, HaritaPin[]>();
|
||||
@@ -194,30 +239,68 @@ export function TercihHaritasi({
|
||||
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
|
||||
const sf = vb.w / HARITA_GENISLIK;
|
||||
const zoomda = Boolean(seciliIl);
|
||||
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
|
||||
const acikPinler = cakismaAc(seciliPinler);
|
||||
const seciliPinler = useMemo(
|
||||
() => (seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []),
|
||||
[ilPinleri, seciliIl],
|
||||
);
|
||||
const acikPinler = useMemo(() => cakismaAc(seciliPinler), [seciliPinler]);
|
||||
|
||||
// Ülke seviyesi toplu pinler: il başına adet/merkez/renk tek geçişte
|
||||
// (üç ayrı reduce + baskinRisk dördüncü geçişti) ve memo'da.
|
||||
const ilOzetleri = useMemo(
|
||||
() =>
|
||||
[...ilPinleri.entries()].map(([il, grup]) => {
|
||||
let adet = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const p of grup) {
|
||||
adet += p.tercihSiralari.length;
|
||||
cx += p.x;
|
||||
cy += p.y;
|
||||
}
|
||||
return {
|
||||
il,
|
||||
adet,
|
||||
cx: cx / grup.length,
|
||||
cy: cy / grup.length,
|
||||
renk: RISK_RENK[baskinRisk(grup)],
|
||||
};
|
||||
}),
|
||||
[ilPinleri],
|
||||
);
|
||||
const ilAdetleri = useMemo(
|
||||
() => new Map(ilOzetleri.map((o) => [o.il, o.adet])),
|
||||
[ilOzetleri],
|
||||
);
|
||||
|
||||
// Alt şerit: seçili ilin tercihleri sıraya göre (memo'suz flatMap+sort
|
||||
// her render'da yeniden kuruluyordu)
|
||||
const seritTercihleri = useMemo(
|
||||
() =>
|
||||
seciliPinler
|
||||
.flatMap((p) => p.tercihSiralari.map((sira) => ({ pin: p, sira })))
|
||||
.sort((a, b) => a.sira - b.sira),
|
||||
[seciliPinler],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
||||
ref={svgMount}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
|
||||
>
|
||||
{/* İl zeminleri */}
|
||||
{cities.map((city) => {
|
||||
const key = normalizeIlAdi(city.name);
|
||||
const iceriyor = ilPinleri.has(key);
|
||||
{NORMAL_SEHIRLER.map(({ city, key }) => {
|
||||
const ilAdet = ilAdetleri.get(key);
|
||||
const iceriyor = ilAdet !== undefined;
|
||||
const secili = key === seciliIl;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
ref={(el) => {
|
||||
if (el) pathRefs.current.set(key, el);
|
||||
else pathRefs.current.delete(key);
|
||||
}}
|
||||
ref={pathKayit.callbacks.get(key)}
|
||||
d={city.path}
|
||||
className={
|
||||
iceriyor
|
||||
@@ -240,10 +323,7 @@ export function TercihHaritasi({
|
||||
>
|
||||
<title>
|
||||
{iceriyor
|
||||
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
|
||||
(a, p) => a + p.tercihSiralari.length,
|
||||
0,
|
||||
)} tercih — yakınlaşmak için tıkla`
|
||||
? `${city.name}: ${ilAdet} tercih — yakınlaşmak için tıkla`
|
||||
: city.name}
|
||||
</title>
|
||||
</path>
|
||||
@@ -252,47 +332,36 @@ export function TercihHaritasi({
|
||||
|
||||
{/* Ülke seviyesi: il başına toplu pin */}
|
||||
{!zoomda
|
||||
? [...ilPinleri.entries()].map(([il, iller]) => {
|
||||
const adet = iller.reduce(
|
||||
(a, p) => a + p.tercihSiralari.length,
|
||||
0,
|
||||
);
|
||||
const cx =
|
||||
iller.reduce((a, p) => a + p.x, 0) / iller.length;
|
||||
const cy =
|
||||
iller.reduce((a, p) => a + p.y, 0) / iller.length;
|
||||
const renk = RISK_RENK[baskinRisk(iller)];
|
||||
return (
|
||||
<g
|
||||
key={il}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSeciliIlDegisti(il)}
|
||||
role="button"
|
||||
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
|
||||
? ilOzetleri.map(({ il, adet, cx, cy, renk }) => (
|
||||
<g
|
||||
key={il}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSeciliIlDegisti(il)}
|
||||
role="button"
|
||||
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
|
||||
>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={11}
|
||||
fill={renk}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={11}
|
||||
fill={renk}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{adet}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})
|
||||
{adet}
|
||||
</text>
|
||||
</g>
|
||||
))
|
||||
: null}
|
||||
|
||||
{/* İl seviyesi: üniversite pinleri */}
|
||||
@@ -426,12 +495,9 @@ export function TercihHaritasi({
|
||||
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
|
||||
</p>
|
||||
<ul className="flex gap-2 overflow-x-auto pb-1">
|
||||
{seciliPinler
|
||||
.flatMap((p) =>
|
||||
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
|
||||
)
|
||||
.sort((a, b) => a.sira - b.sira)
|
||||
.map(({ pin, sira }) => (
|
||||
{seritTercihleri.map(({ pin, sira }) => {
|
||||
const logo = pin.kilitli ? null : uniLogoYoluFromAd(pin.universite);
|
||||
return (
|
||||
<li key={sira} className="shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -451,9 +517,9 @@ export function TercihHaritasi({
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{uniLogoYoluFromAd(pin.universite) ? (
|
||||
{logo ? (
|
||||
<Image
|
||||
src={uniLogoYoluFromAd(pin.universite)!}
|
||||
src={logo}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
@@ -468,7 +534,8 @@ export function TercihHaritasi({
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
// Sıra+sihirbaz kapısı ilk açılış isteğine kadar hiç inmez (bundle-conditional).
|
||||
// Bir kez açıldıktan sonra mount kalır ki Dialog'un kapanış animasyonu ve
|
||||
// oturum içi state'i bozulmasın.
|
||||
|
||||
export const TercihProfiliKapisiLazy = dynamic(
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useProfilKapisi } from "../hooks/use-tercih-profili";
|
||||
|
||||
const TercihProfiliKapisiIc = dynamic(
|
||||
() => import("./tercih-profili-kapisi").then((m) => m.TercihProfiliKapisi),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/** Kapıyı açan butonların hover/focus'unda chunk'ı önden ısıt. */
|
||||
export function preloadTercihProfiliKapisi() {
|
||||
void import("./tercih-profili-kapisi");
|
||||
}
|
||||
|
||||
export function TercihProfiliKapisiLazy() {
|
||||
const { acik } = useProfilKapisi();
|
||||
const [gerekli, setGerekli] = useState(false);
|
||||
// Render sırasında koşullu setState: ilk açılışta mandallanır (React'in
|
||||
// "adjust state during render" deseni — effect'e gerek yok).
|
||||
if (acik && !gerekli) setGerekli(true);
|
||||
return gerekli ? <TercihProfiliKapisiIc /> : null;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/features/sihirbaz/components/sihirbaz-adimlar-lazy";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import { type SihirbazSecimleri, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
@@ -145,12 +146,12 @@ function KapıIcerik({
|
||||
setYukleniyor(true);
|
||||
setHata(null);
|
||||
try {
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${siraDeger}&tur=${turDeger}`),
|
||||
const [veri, oturum] = await Promise.all([
|
||||
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||||
`/api/facetler?sira=${siraDeger}&tur=${turDeger}`,
|
||||
),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(siraDeger);
|
||||
setTur(turDeger);
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import "server-only";
|
||||
import { searchByRank } from "@/lib/db";
|
||||
import type { PuanTuruKey, RankResults } from "@/types/yokatlas";
|
||||
import type {
|
||||
Program,
|
||||
ProgramListeSatiri,
|
||||
PuanTuruKey,
|
||||
RankResultsListe,
|
||||
} from "@/types/yokatlas";
|
||||
|
||||
// Tabloda kullanılmayan alanlar (fakulte, puan2025) client'a serileşmesin.
|
||||
// Alanlar tek tek seçilir: Program'a alan eklenirse ProgramListeSatiri
|
||||
// üzerinden derleyici burada da karar vermeye zorlar.
|
||||
export function programOzetle(p: Program): ProgramListeSatiri {
|
||||
return {
|
||||
id: p.id,
|
||||
isim: p.isim,
|
||||
universite: p.universite,
|
||||
unitur: p.unitur,
|
||||
il: p.il,
|
||||
tur: p.tur,
|
||||
sure: p.sure,
|
||||
sira2025: p.sira2025,
|
||||
sira2024: p.sira2024,
|
||||
sira2023: p.sira2023,
|
||||
sira2022: p.sira2022,
|
||||
sira2021: p.sira2021,
|
||||
kontenjan2025: p.kontenjan2025,
|
||||
yerlesen2025: p.yerlesen2025,
|
||||
};
|
||||
}
|
||||
|
||||
// /sonuc'un ham tablosu: yalnızca sıralamayla ulaşılabilen programlar,
|
||||
// hayal/dengeli/garanti dilimli, dilim başına ilk 20 satır.
|
||||
export function siraylaProgramAra(
|
||||
sira: number,
|
||||
tur: PuanTuruKey,
|
||||
): RankResults {
|
||||
return searchByRank(sira, tur, { limitPerBucket: 20 });
|
||||
): RankResultsListe {
|
||||
const ham = searchByRank(sira, tur, { limitPerBucket: 20 });
|
||||
return {
|
||||
hayal: ham.hayal.map(programOzetle),
|
||||
dengeli: ham.dengeli.map(programOzetle),
|
||||
garanti: ham.garanti.map(programOzetle),
|
||||
toplam: ham.toplam,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,8 +87,42 @@ export function useCekmeceAcik(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Üyelik kontrolü O(1): id Set'i tembel kurulur, `liste` her mutasyonda yeni
|
||||
// referans aldığı için referans karşılaştırması mükemmel geçersizleme anahtarı.
|
||||
let idSet: Set<string> | null = null;
|
||||
let idSetKaynak: ManuelTercih[] | null = null;
|
||||
function idSetAl(): Set<string> {
|
||||
if (idSetKaynak !== liste || idSet === null) {
|
||||
idSet = new Set(liste.map((t) => t.id));
|
||||
idSetKaynak = liste;
|
||||
}
|
||||
return idSet;
|
||||
}
|
||||
|
||||
export function listedeMi(id: string): boolean {
|
||||
return liste.some((t) => t.id === id);
|
||||
return idSetAl().has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Satır başına üyelik aboneliği (program-ekle-butonu 50+ satırda render
|
||||
* ediliyor): snapshot primitive boolean olduğu için liste değişse bile yalnız
|
||||
* üyeliği değişen butonlar yeniden render olur — tüm listeye abone olup her
|
||||
* mutasyonda 50 satırı uyandırmak yerine.
|
||||
*/
|
||||
export function useListedeMi(id: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => idSetAl().has(id),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function useListeDoluMu(): boolean {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => liste.length >= MANUEL_LISTE_MAX,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */
|
||||
|
||||
@@ -14,9 +14,11 @@ export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
|
||||
const geriDonus = siparis
|
||||
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
|
||||
: "/paket";
|
||||
const session = await verifySession(geriDonus);
|
||||
|
||||
const order = siparis ? await siparisGetir(siparis) : null;
|
||||
// Oturum ve sipariş sorgusu bağımsız; sahiplik kontrolü aşağıda yapılıyor
|
||||
const [session, order] = await Promise.all([
|
||||
verifySession(geriDonus),
|
||||
siparis ? siparisGetir(siparis) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Yalnızca kendi siparişi görüntülenebilir
|
||||
const sahibiMi = order?.userId === session.user.id;
|
||||
|
||||
@@ -14,11 +14,12 @@ export async function baslatOdeme(
|
||||
_prev: OdemeBaslatDurum,
|
||||
formData: FormData,
|
||||
): Promise<OdemeBaslatDurum> {
|
||||
const session = await verifySession("/paket");
|
||||
// Ucuz senkron doğrulama pahalı oturum turundan önce
|
||||
const product = formData.get("urun");
|
||||
if (product !== "paket" && product !== "topup") {
|
||||
return { error: "Geçersiz ürün." };
|
||||
}
|
||||
const session = await verifySession("/paket");
|
||||
const urun = URUNLER[product];
|
||||
|
||||
const orderId = crypto.randomUUID();
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ArrowRight } from "lucide-react";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { PUAN_TURU_ETIKET, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import type { DilimKey, SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
|
||||
export type DemoVeri = {
|
||||
facetler: SihirbazFacetleri;
|
||||
@@ -48,12 +49,11 @@ async function facetGetir(
|
||||
): Promise<{ facetler: SihirbazFacetleri; dilimToplam?: Record<DilimKey, number> } | null> {
|
||||
const params = new URLSearchParams({ sira: String(sira), tur, dilimler: "1" });
|
||||
try {
|
||||
const res = await fetch(`/api/facetler?${params}`, { signal });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as {
|
||||
// Demo slider'ı aynı sıraya dönünce cache'ten okur (dedup+TTL)
|
||||
return await onbellekliJsonGetir<{
|
||||
facetler: SihirbazFacetleri;
|
||||
dilimToplam?: Record<DilimKey, number>;
|
||||
};
|
||||
}>(`/api/facetler?${params}`, { signal });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RaporListesi } from "./rapor-listesi";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { TercihHaritasi } from "@/features/liste/components/tercih-haritasi";
|
||||
import { TercihHaritasiLazy } from "@/features/liste/components/tercih-haritasi-lazy";
|
||||
import { pinleriTuret } from "@/lib/harita-pinler";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
|
||||
@@ -92,7 +92,7 @@ export function ListemGovde({
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<TercihHaritasi
|
||||
<TercihHaritasiLazy
|
||||
pinler={pinVerisi.pinler}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
|
||||
// sunucuda zaten maskelidir).
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
@@ -121,14 +121,20 @@ export function RaporListesi({
|
||||
/** Detaydaki "Haritada gör" — il DB biçiminde */
|
||||
onHaritadaGor?: (il: string | null, sira: number) => void;
|
||||
}) {
|
||||
const [acik, setAcik] = useState<Set<number>>(new Set());
|
||||
// Lazy init: boş Set her render'da boşuna allocate edilmesin
|
||||
const [acik, setAcik] = useState<Set<number>>(() => new Set());
|
||||
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
|
||||
|
||||
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
|
||||
// Haritadan gelen seçim: satırın açılması render sırasındaki önceki-prop
|
||||
// deseniyle (effect'te setState çift render yaratıyordu); yalnızca DOM işi
|
||||
// olan scrollIntoView effect'te kalır.
|
||||
const [oncekiAcikSira, setOncekiAcikSira] = useState(acikSira);
|
||||
if (acikSira !== oncekiAcikSira) {
|
||||
setOncekiAcikSira(acikSira);
|
||||
if (acikSira != null) setAcik((mevcut) => new Set(mevcut).add(acikSira));
|
||||
}
|
||||
useEffect(() => {
|
||||
if (acikSira == null) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAcik((mevcut) => new Set(mevcut).add(acikSira));
|
||||
satirRefs.current
|
||||
.get(acikSira)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -145,12 +151,14 @@ export function RaporListesi({
|
||||
|
||||
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
|
||||
const iller = new Set(
|
||||
rapor.tercihler
|
||||
.map((t) => rapor.programlar[t.programId]?.il)
|
||||
.filter((il): il is string => Boolean(il)),
|
||||
);
|
||||
const tekIl = iller.size === 1 ? [...iller][0] : null;
|
||||
const tekIl = useMemo(() => {
|
||||
const iller = new Set<string>();
|
||||
for (const t of rapor.tercihler) {
|
||||
const il = rapor.programlar[t.programId]?.il;
|
||||
if (il) iller.add(il);
|
||||
}
|
||||
return iller.size === 1 ? [...iller][0] : null;
|
||||
}, [rapor]);
|
||||
|
||||
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
@@ -172,7 +180,7 @@ export function RaporListesi({
|
||||
else satirRefs.current.delete(t.sira);
|
||||
}}
|
||||
onClick={() => tiklandi(t.sira)}
|
||||
className="cursor-pointer"
|
||||
className="kt-cv-satir cursor-pointer"
|
||||
>
|
||||
<TableCell className="pl-4 pr-0">
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
ChatContainerScrollAnchor,
|
||||
} from "@/components/ui/chat-container";
|
||||
import { Message, MessageContent } from "@/components/ui/message";
|
||||
import { preloadMarkdown } from "@/components/ui/markdown-lazy";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputAction,
|
||||
@@ -90,12 +92,22 @@ export function SohbetClient({
|
||||
const [kredi, setKredi] = useState(baslangicKredi);
|
||||
const router = useRouter();
|
||||
|
||||
// Markdown chunk'ını sohbet görünür olur olmaz ısıt: ilk danışman cevabı
|
||||
// (veya geçmiş mesajlar) çizilirken lazy sınırında düz-metin flash'ı olmasın.
|
||||
useEffect(() => {
|
||||
void preloadMarkdown();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
|
||||
// popup gibi geç mount olan yerlerde eski davranış sürer.
|
||||
if (initialMesajlar != null) return;
|
||||
fetch("/api/soru")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
// ttlMs: 0 = yalnız in-flight dedup — /sonuc popup'ı ile /listem aynı anda
|
||||
// mount olursa tek istek; kredi/geçmiş POST sonrası değiştiği için
|
||||
// TTL cache'i bilinçli kapalı.
|
||||
onbellekliJsonGetir<{ mesajlar?: Mesaj[]; kredi?: number }>("/api/soru", {
|
||||
ttlMs: 0,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
||||
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
||||
@@ -157,8 +169,12 @@ export function SohbetClient({
|
||||
if (done) break;
|
||||
birikmis += decoder.decode(value, { stream: true });
|
||||
const anlik = birikmis;
|
||||
setMesajlar((m) =>
|
||||
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
|
||||
// Chunk başına gelen güncelleme acil değil: transition'a alınca
|
||||
// kullanıcı stream sürerken input'a takılmadan yazabilir
|
||||
startTransition(() =>
|
||||
setMesajlar((m) =>
|
||||
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Header'daki kredi pill'i layout'ta server-render edilir ve client
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// Mermaid ağır bir paket olduğu için useEffect içinde dinamik import edilir;
|
||||
// sayfa paketine girmez.
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
// Şemanın "el çizimi" görünümüne uyan el yazısı fontu (Sweet Cucumber
|
||||
@@ -30,38 +30,58 @@ export function ElCizimiSema({
|
||||
figcaption?: React.ReactNode;
|
||||
}) {
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
const figureRef = useRef<HTMLElement>(null);
|
||||
// mermaid.render'ın istediği DOM id'si: useId'nin ":" karakterleri geçersiz
|
||||
const semaId = `sema-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
|
||||
|
||||
// ~1 MB'lık mermaid mount'ta değil, şema görünürlüğe yaklaşınca insin —
|
||||
// şemalar hep fold altında (/meraklisina sayfa sonu, rehber makale ortası).
|
||||
useEffect(() => {
|
||||
const el = figureRef.current;
|
||||
if (!el) return;
|
||||
let iptal = false;
|
||||
import("mermaid").then(async ({ default: mermaid }) => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
look: "handDrawn",
|
||||
handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik)
|
||||
theme: "base",
|
||||
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
|
||||
themeVariables: {
|
||||
primaryColor: "#ffffff",
|
||||
primaryBorderColor: "#e2e8f0",
|
||||
primaryTextColor: "#0f172a",
|
||||
lineColor: "#94a3b8",
|
||||
edgeLabelBackground: "#ffffff",
|
||||
const io = new IntersectionObserver(
|
||||
(girisler) => {
|
||||
if (!girisler.some((g) => g.isIntersecting)) return;
|
||||
io.disconnect();
|
||||
cizdir();
|
||||
},
|
||||
// Kullanıcı şemaya varmadan yükleme başlasın
|
||||
{ rootMargin: "200px 0px" },
|
||||
);
|
||||
io.observe(el);
|
||||
|
||||
function cizdir() {
|
||||
import("mermaid").then(async ({ default: mermaid }) => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
look: "handDrawn",
|
||||
handDrawnSeed: 7, // her render'da aynı çizgi titremesi (deterministik)
|
||||
theme: "base",
|
||||
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
|
||||
fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü
|
||||
},
|
||||
themeVariables: {
|
||||
primaryColor: "#ffffff",
|
||||
primaryBorderColor: "#e2e8f0",
|
||||
primaryTextColor: "#0f172a",
|
||||
lineColor: "#94a3b8",
|
||||
edgeLabelBackground: "#ffffff",
|
||||
fontFamily: `${elYazisi.style.fontFamily}, cursive`,
|
||||
fontSize: "17px", // script fontun x-yüksekliği düşük; okunurluk için büyütüldü
|
||||
},
|
||||
});
|
||||
const sonuc = await mermaid.render(semaId, tanim);
|
||||
if (!iptal) setSvg(sonuc.svg);
|
||||
});
|
||||
const sonuc = await mermaid.render(semaId, tanim);
|
||||
if (!iptal) setSvg(sonuc.svg);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
iptal = true;
|
||||
io.disconnect();
|
||||
};
|
||||
}, [semaId, tanim]);
|
||||
|
||||
return (
|
||||
<figure className="mt-6">
|
||||
<figure ref={figureRef} className="mt-6">
|
||||
{svg ? (
|
||||
<div
|
||||
role="img"
|
||||
|
||||
@@ -7,5 +7,7 @@ export {
|
||||
getAllRehberler,
|
||||
getAllRehberSlugs,
|
||||
getRehber,
|
||||
getRehberAramaKayitlari,
|
||||
type RehberOzet,
|
||||
type RehberAramaKaydi,
|
||||
} from "@/lib/rehber";
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./sihirbaz-adimlar-lazy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
@@ -91,12 +92,12 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
try {
|
||||
// Oturum bilgisi facet isteğiyle PARALEL çözülür; sayfa statik kalır
|
||||
// (hero formundaki desenle aynı — bkz. hero-form.tsx).
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${value}&tur=${tur}`),
|
||||
const [veri, oturum] = await Promise.all([
|
||||
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||||
`/api/facetler?sira=${value}&tur=${tur}`,
|
||||
),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(value);
|
||||
setFacetler(veri.facetler);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "./sihirbaz-adimlar-lazy";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
@@ -67,12 +68,13 @@ export function HeroForm() {
|
||||
try {
|
||||
// Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür:
|
||||
// landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır).
|
||||
const [res, oturum] = await Promise.all([
|
||||
fetch(`/api/facetler?sira=${value}&tur=${tur}`),
|
||||
// Facetler dedup+TTL cache'li: aynı sıra/tür tekrar girilirse ağa çıkmaz.
|
||||
const [veri, oturum] = await Promise.all([
|
||||
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||||
`/api/facetler?sira=${value}&tur=${tur}`,
|
||||
),
|
||||
authClient.getSession().catch(() => null),
|
||||
]);
|
||||
if (!res.ok) throw new Error();
|
||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
||||
setGirisli(Boolean(oturum?.data?.user));
|
||||
setSira(value);
|
||||
setFacetler(veri.facetler);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu
|
||||
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import {
|
||||
HARITA_GENISLIK,
|
||||
@@ -12,6 +13,14 @@ import {
|
||||
normalizeIlAdi,
|
||||
} from "@/lib/harita";
|
||||
|
||||
// normalizeIlAdi (3 replace + toLocaleUpperCase) modül yükünde İL BAŞINA BİR
|
||||
// kez koşar; eskiden her render'da sort comparator içinde ~1000 kez
|
||||
// çağrılıyordu (81 il × ~çift log n karşılaştırma × 2 çağrı).
|
||||
const NORMAL_SEHIRLER = cities.map((city) => ({
|
||||
city,
|
||||
key: normalizeIlAdi(city.name),
|
||||
}));
|
||||
|
||||
export function IlSecimHaritasi({
|
||||
iller,
|
||||
secili,
|
||||
@@ -22,9 +31,20 @@ export function IlSecimHaritasi({
|
||||
secili: string[];
|
||||
onToggle: (il: string) => void;
|
||||
}) {
|
||||
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
|
||||
const maxAdet = Math.max(1, ...iller.map((i) => i.adet));
|
||||
const seciliSet = new Set(secili);
|
||||
const { adetler, maxAdet } = useMemo(() => {
|
||||
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
|
||||
let maxAdet = 1;
|
||||
for (const i of iller) if (i.adet > maxAdet) maxAdet = i.adet;
|
||||
return { adetler, maxAdet };
|
||||
}, [iller]);
|
||||
const seciliSet = useMemo(() => new Set(secili), [secili]);
|
||||
const siraliSehirler = useMemo(
|
||||
() =>
|
||||
[...NORMAL_SEHIRLER].sort(
|
||||
(a, b) => Number(seciliSet.has(a.key)) - Number(seciliSet.has(b.key)),
|
||||
),
|
||||
[seciliSet],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -35,14 +55,7 @@ export function IlSecimHaritasi({
|
||||
aria-label="Okumak istediğin illeri haritadan seç"
|
||||
>
|
||||
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
|
||||
{[...cities]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(seciliSet.has(normalizeIlAdi(a.name))) -
|
||||
Number(seciliSet.has(normalizeIlAdi(b.name))),
|
||||
)
|
||||
.map((city) => {
|
||||
const key = normalizeIlAdi(city.name);
|
||||
{siraliSehirler.map(({ city, key }) => {
|
||||
const adet = adetler.get(key);
|
||||
const seciliMi = seciliSet.has(key);
|
||||
const yogunluk = adet ? adet / maxAdet : 0;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Adımlar kademeli süzülür: 2. adımın illeri seçili kategorilere, 3. adımın
|
||||
// üniversite tipi sayıları kategori+il'e göre /api/facetler'den tazelenir.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, X } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import useMeasure from "react-use-measure";
|
||||
@@ -17,6 +17,7 @@ import { TransitionPanel } from "@/components/core/transition-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IlSecimHaritasi } from "./il-secim-haritasi";
|
||||
import { normalizeIlAdi } from "@/lib/harita";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import { olay } from "@/lib/analitik";
|
||||
import { trBaslikDuzeni } from "@/lib/slug";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
@@ -85,6 +86,17 @@ export function SihirbazAdimlar({
|
||||
// tipi sayıları adıma girerken o anki seçimlerle tazelenir. Fetch başarısız
|
||||
// olursa eldeki (daha geniş) liste kalır — sihirbaz asla kilitlenmez.
|
||||
const [canliFacetler, setCanliFacetler] = useState(facetler);
|
||||
// 81 elemanlı listeyi her çip tıklamasında yeniden sıralamamak/filtrelememek
|
||||
// için memo; haritaya giden dizinin referansı da sabit kalır (gereksiz
|
||||
// IlSecimHaritasi render'ı biter).
|
||||
const siraliIller = useMemo(
|
||||
() => [...canliFacetler.iller].sort((a, b) => b.adet - a.adet),
|
||||
[canliFacetler.iller],
|
||||
);
|
||||
const haritaIlleri = useMemo(
|
||||
() => canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il)),
|
||||
[canliFacetler.iller],
|
||||
);
|
||||
const [facetYukleniyor, setFacetYukleniyor] = useState(false);
|
||||
useEffect(() => {
|
||||
if (adim === 0) return;
|
||||
@@ -94,10 +106,12 @@ export function SihirbazAdimlar({
|
||||
if (adim === 2) for (const i of iller) params.append("il", i);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch başlangıç işareti
|
||||
setFacetYukleniyor(true);
|
||||
fetch(`/api/facetler?${params}`, { signal: ctrl.signal })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((veri: { facetler: SihirbazFacetleri } | null) => {
|
||||
if (!veri) return;
|
||||
// Cache'li: 2↔3 adımları arasında gidip gelmek aynı URL'i yeniden çekmez
|
||||
onbellekliJsonGetir<{ facetler: SihirbazFacetleri }>(
|
||||
`/api/facetler?${params}`,
|
||||
{ signal: ctrl.signal },
|
||||
)
|
||||
.then((veri) => {
|
||||
setCanliFacetler((mevcut) => ({
|
||||
...mevcut,
|
||||
iller: veri.facetler.iller,
|
||||
@@ -250,9 +264,7 @@ export function SihirbazAdimlar({
|
||||
facetYukleniyor ? "animate-pulse opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{[...canliFacetler.iller]
|
||||
.sort((a, b) => b.adet - a.adet)
|
||||
.map((i) => (
|
||||
{siraliIller.map((i) => (
|
||||
<Cip
|
||||
key={i.il}
|
||||
secili={iller.includes(i.il)}
|
||||
@@ -286,7 +298,7 @@ export function SihirbazAdimlar({
|
||||
{/* Harita geniş ekranda görsel destek olarak kalır */}
|
||||
<div className="mt-4 hidden sm:block">
|
||||
<IlSecimHaritasi
|
||||
iller={canliFacetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
|
||||
iller={haritaIlleri}
|
||||
secili={iller}
|
||||
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { animate } from "motion";
|
||||
import { animate } from "motion/mini";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PixelField } from "@/components/pixel-decor";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
|
||||
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Trophy } from "lucide-react";
|
||||
@@ -18,17 +18,14 @@ import {
|
||||
export function SiraGerekli() {
|
||||
const router = useRouter();
|
||||
const { profil, profilYuklendi } = useProfilKapisi();
|
||||
const [yonleniyor, setYonleniyor] = useState(false);
|
||||
|
||||
// "Yönleniyor" durumu ayrı state gerektirmez: profil varken aşağıdaki koşul
|
||||
// zaten bekleme ekranını gösteriyor; effect yalnızca yönlendirmeyi yapar.
|
||||
useEffect(() => {
|
||||
if (!profilYuklendi) return;
|
||||
if (profil) {
|
||||
setYonleniyor(true);
|
||||
router.replace(sonucHref(profil));
|
||||
}
|
||||
if (profilYuklendi && profil) router.replace(sonucHref(profil));
|
||||
}, [profil, profilYuklendi, router]);
|
||||
|
||||
if (!profilYuklendi || yonleniyor || profil) {
|
||||
if (!profilYuklendi || profil) {
|
||||
return (
|
||||
<main className="mx-auto flex max-w-2xl flex-col items-center px-4 py-24 text-center">
|
||||
<p className="text-sm text-slate-500">Sıralaman kontrol ediliyor…</p>
|
||||
|
||||
@@ -12,16 +12,34 @@ function profilDegistiginiYayinla(): void {
|
||||
window.dispatchEvent(new Event(PROFIL_DEGISTI_EVENT));
|
||||
}
|
||||
|
||||
// tercihProfiliOku 18 farklı yerden çağrılıyor ve tek bir "+" tıklaması bile
|
||||
// 3 kez okuyabiliyor; her çağrıda localStorage + JSON.parse + şema doğrulama
|
||||
// yapmamak için modül seviyesi cache (js-cache-storage). Geçersizleme iki
|
||||
// kanaldan: aynı sekmede PROFIL_DEGISTI_EVENT, diğer sekmelerde "storage".
|
||||
// undefined = henüz okunmadı, null = okundu ve profil yok.
|
||||
let profilCache: TercihProfili | null | undefined;
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, () => {
|
||||
profilCache = undefined;
|
||||
});
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === SIHIRBAZ_STORAGE_KEY || e.key === null) {
|
||||
profilCache = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Client'ta SIHIRBAZ_STORAGE_KEY'den doğrulanmış profil okur. */
|
||||
export function tercihProfiliOku(): TercihProfili | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (profilCache !== undefined) return profilCache;
|
||||
try {
|
||||
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
|
||||
if (!ham) return null;
|
||||
return tercihProfiliDogrula(JSON.parse(ham));
|
||||
profilCache = ham ? tercihProfiliDogrula(JSON.parse(ham)) : null;
|
||||
} catch {
|
||||
return null;
|
||||
profilCache = null;
|
||||
}
|
||||
return profilCache;
|
||||
}
|
||||
|
||||
/** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */
|
||||
@@ -32,6 +50,7 @@ export function tercihProfiliYaz(profil: TercihProfili): void {
|
||||
try {
|
||||
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
|
||||
} catch {}
|
||||
profilCache = dogru;
|
||||
profilDegistiginiYayinla();
|
||||
}
|
||||
|
||||
@@ -41,6 +60,7 @@ export function tercihProfiliSil(): void {
|
||||
try {
|
||||
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
|
||||
} catch {}
|
||||
profilCache = null;
|
||||
profilDegistiginiYayinla();
|
||||
}
|
||||
|
||||
|
||||
62
src/lib/istek-cache.ts
Normal file
62
src/lib/istek-cache.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// İstemci tarafı GET istekleri için modül seviyesi in-flight dedup + TTL
|
||||
// cache. SWR bağımlılığı yerine minimal el yapımı katman: /api/facetler beş
|
||||
// farklı bileşenden aynı parametrelerle çağrılıyor, katalog araması aynı
|
||||
// sorguyu tekrar tekrar atıyordu.
|
||||
//
|
||||
// - Aynı URL uçuştayken ikinci çağrı aynı promise'i bekler (dedup).
|
||||
// - Başarılı yanıt ttlMs boyunca bellekte kalır; ttlMs: 0 = yalnız dedup
|
||||
// (kullanıcıya özel, mutasyon sonrası tazelenmesi gereken uçlar için).
|
||||
// - Paylaşılan fetch'e caller signal'ı GEÇİLMEZ: bir abonenin iptali
|
||||
// diğerlerinin isteğini düşürmesin. İptal yalnızca o abonenin bekleyişini
|
||||
// AbortError ile sonlandırır (katalog aramasının debounce/abort akışı
|
||||
// davranış değiştirmeden buna bağlanır).
|
||||
|
||||
type Kayit = { zaman: number; veri: unknown };
|
||||
|
||||
const bellek = new Map<string, Kayit>();
|
||||
const ucusta = new Map<string, Promise<unknown>>();
|
||||
const MAX_KAYIT = 50;
|
||||
const VARSAYILAN_TTL_MS = 5 * 60_000;
|
||||
|
||||
export async function onbellekliJsonGetir<T>(
|
||||
url: string,
|
||||
{
|
||||
ttlMs = VARSAYILAN_TTL_MS,
|
||||
signal,
|
||||
}: { ttlMs?: number; signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const kayit = bellek.get(url);
|
||||
if (kayit && ttlMs > 0 && Date.now() - kayit.zaman < ttlMs) {
|
||||
return kayit.veri as T;
|
||||
}
|
||||
|
||||
let soz = ucusta.get(url);
|
||||
if (!soz) {
|
||||
soz = fetch(url)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`istek-cache: ${res.status} (${url})`);
|
||||
const veri: unknown = await res.json();
|
||||
if (ttlMs > 0) {
|
||||
bellek.set(url, { zaman: Date.now(), veri });
|
||||
// Basit LRU kırpması: en eski kayıt düşer (insertion order)
|
||||
if (bellek.size > MAX_KAYIT) {
|
||||
const enEski = bellek.keys().next().value;
|
||||
if (enEski !== undefined) bellek.delete(enEski);
|
||||
}
|
||||
}
|
||||
return veri;
|
||||
})
|
||||
.finally(() => ucusta.delete(url));
|
||||
ucusta.set(url, soz);
|
||||
}
|
||||
|
||||
if (!signal) return soz as Promise<T>;
|
||||
return new Promise<T>((cozul, reddet) => {
|
||||
const iptalEt = () => reddet(new DOMException("Aborted", "AbortError"));
|
||||
if (signal.aborted) return iptalEt();
|
||||
signal.addEventListener("abort", iptalEt, { once: true });
|
||||
(soz as Promise<T>).then(cozul, reddet).finally(() => {
|
||||
signal.removeEventListener("abort", iptalEt);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { after } from "next/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { appDb, schema } from "./appdb";
|
||||
import { grantCredits, URUNLER } from "./credits";
|
||||
@@ -62,15 +63,19 @@ export async function odemeyiSonuclandir(
|
||||
|
||||
// Dönüşüm kaydı. Koşullu geçişin ardında olduğu için sipariş başına tam bir
|
||||
// kez düşer (self-healing fallback tekrar çağrılsa da rowsAffected 0 döner).
|
||||
await sunucuOlayi("odeme_tamamlandi", {
|
||||
kullaniciId: order.userId,
|
||||
pathname: "/odeme/sonuc",
|
||||
ozellikler: {
|
||||
urun: order.product,
|
||||
kredi: order.credits,
|
||||
tutar: order.amountKurus / 100,
|
||||
},
|
||||
});
|
||||
// after(): yanıt gönderildikten sonra koşar — callback redirect'ini ve
|
||||
// /odeme/sonuc render'ını Rybbit'in 3 sn'lik timeout'u bloklayamaz.
|
||||
after(() =>
|
||||
sunucuOlayi("odeme_tamamlandi", {
|
||||
kullaniciId: order.userId,
|
||||
pathname: "/odeme/sonuc",
|
||||
ozellikler: {
|
||||
urun: order.product,
|
||||
kredi: order.credits,
|
||||
tutar: order.amountKurus / 100,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return "paid";
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ export type RehberOzet = {
|
||||
tarih: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Header'daki ⌘K aramasına giden asgari alan seti. `tarih` client'ta hiç
|
||||
* okunmadığı için RSC payload'ına taşınmaz (root layout üzerinden her
|
||||
* sayfaya serileşiyordu).
|
||||
*/
|
||||
export type RehberAramaKaydi = Pick<RehberOzet, "slug" | "baslik" | "aciklama">;
|
||||
|
||||
export type RehberSema = {
|
||||
/** Mermaid tanımı (aria/altyazi yönergeleri ayıklanmış hâli) */
|
||||
tanim: string;
|
||||
@@ -40,6 +47,16 @@ export type RehberYazi = RehberOzet & { parcalar: RehberParca[] };
|
||||
|
||||
const REHBER_DIZIN = path.join(process.cwd(), "content", "rehber");
|
||||
|
||||
// SiteHeader root layout'ta olduğu için getAllRehberler her sayfa
|
||||
// render/prerender'ında koşuyor; cache'siz hâli sayfa başına 24 dosya okuma +
|
||||
// 24 marked.parse demekti. Yalnızca prod'da cache'lenir — dev'de içerik
|
||||
// düzenlenirken taze fs okuması korunur (katalog.ts/db.ts globalThis deseni).
|
||||
const globalRehber = globalThis as unknown as {
|
||||
rehberYazilar?: Map<string, RehberYazi | null>;
|
||||
rehberOzetler?: RehberOzet[];
|
||||
};
|
||||
const CACHE_AKTIF = process.env.NODE_ENV === "production";
|
||||
|
||||
function parseFrontmatter(raw: string): {
|
||||
meta: Record<string, string>;
|
||||
body: string;
|
||||
@@ -105,21 +122,62 @@ function parcala(slug: string, body: string): RehberParca[] {
|
||||
return parcalar;
|
||||
}
|
||||
|
||||
function oku(slug: string): RehberYazi | null {
|
||||
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
|
||||
if (!fs.existsSync(dosya)) return null;
|
||||
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
|
||||
function metaDogrula(
|
||||
slug: string,
|
||||
meta: Record<string, string>,
|
||||
): asserts meta is { baslik: string; aciklama: string; tarih: string } {
|
||||
if (!meta.baslik || !meta.aciklama || !meta.tarih) {
|
||||
throw new Error(
|
||||
`rehber: ${slug}.md frontmatter eksik (baslik/aciklama/tarih zorunlu)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function oku(slug: string): RehberYazi | null {
|
||||
if (CACHE_AKTIF) {
|
||||
const onbellek = globalRehber.rehberYazilar?.get(slug);
|
||||
if (onbellek !== undefined) return onbellek;
|
||||
}
|
||||
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
|
||||
let yazi: RehberYazi | null = null;
|
||||
if (fs.existsSync(dosya)) {
|
||||
const { meta, body } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
|
||||
metaDogrula(slug, meta);
|
||||
yazi = {
|
||||
slug,
|
||||
baslik: meta.baslik,
|
||||
aciklama: meta.aciklama,
|
||||
tarih: meta.tarih,
|
||||
parcalar: parcala(slug, body),
|
||||
};
|
||||
}
|
||||
if (CACHE_AKTIF) {
|
||||
(globalRehber.rehberYazilar ??= new Map()).set(slug, yazi);
|
||||
}
|
||||
return yazi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste görünümleri için fast-path: yalnızca frontmatter parse edilir,
|
||||
* `parcala()`/`marked.parse` hiç çağrılmaz (özetler `parcalar`ı kullanmıyor).
|
||||
* Tam parse zaten cache'teyse dosyaya inmeden onu kırpar.
|
||||
*/
|
||||
function ozetOku(slug: string): RehberOzet | null {
|
||||
const tam = CACHE_AKTIF ? globalRehber.rehberYazilar?.get(slug) : undefined;
|
||||
if (tam !== undefined) {
|
||||
if (tam === null) return null;
|
||||
const { slug: s, baslik, aciklama, tarih } = tam;
|
||||
return { slug: s, baslik, aciklama, tarih };
|
||||
}
|
||||
const dosya = path.join(REHBER_DIZIN, `${slug}.md`);
|
||||
if (!fs.existsSync(dosya)) return null;
|
||||
const { meta } = parseFrontmatter(fs.readFileSync(dosya, "utf8"));
|
||||
metaDogrula(slug, meta);
|
||||
return {
|
||||
slug,
|
||||
baslik: meta.baslik,
|
||||
aciklama: meta.aciklama,
|
||||
tarih: meta.tarih,
|
||||
parcalar: parcala(slug, body),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,14 +197,22 @@ export function getRehber(slug: string): RehberYazi | null {
|
||||
}
|
||||
|
||||
export function getAllRehberler(): RehberOzet[] {
|
||||
return getAllRehberSlugs()
|
||||
.map((slug) => oku(slug))
|
||||
.filter((y): y is RehberYazi => y !== null)
|
||||
.map(({ slug, baslik, aciklama, tarih }) => ({
|
||||
slug,
|
||||
baslik,
|
||||
aciklama,
|
||||
tarih,
|
||||
}))
|
||||
if (CACHE_AKTIF && globalRehber.rehberOzetler) {
|
||||
return globalRehber.rehberOzetler;
|
||||
}
|
||||
const ozetler = getAllRehberSlugs()
|
||||
.map((slug) => ozetOku(slug))
|
||||
.filter((y): y is RehberOzet => y !== null)
|
||||
.sort((a, b) => b.tarih.localeCompare(a.tarih));
|
||||
if (CACHE_AKTIF) globalRehber.rehberOzetler = ozetler;
|
||||
return ozetler;
|
||||
}
|
||||
|
||||
/** Header aramasının RSC payload'ına giden kırpılmış liste. */
|
||||
export function getRehberAramaKayitlari(): RehberAramaKaydi[] {
|
||||
return getAllRehberler().map(({ slug, baslik, aciklama }) => ({
|
||||
slug,
|
||||
baslik,
|
||||
aciklama,
|
||||
}));
|
||||
}
|
||||
@@ -41,6 +41,20 @@ export type RankResults = {
|
||||
toplam: Record<DilimKey, number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* /sonuc ham tablosunun satırı: tablo `fakulte` ve `puan2025`'i hiç
|
||||
* kullanmadığı için bu alanlar client'a serileştirilmez (satır başına en
|
||||
* uzun string alanlardan biri fakulte). Katalog sayfaları tam Program kullanır.
|
||||
*/
|
||||
export type ProgramListeSatiri = Omit<Program, "fakulte" | "puan2025">;
|
||||
|
||||
export type RankResultsListe = {
|
||||
hayal: ProgramListeSatiri[];
|
||||
dengeli: ProgramListeSatiri[];
|
||||
garanti: ProgramListeSatiri[];
|
||||
toplam: Record<DilimKey, number>;
|
||||
};
|
||||
|
||||
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
|
||||
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
|
||||
export type UniturGrubu = "devlet" | "vakif";
|
||||
|
||||
Reference in New Issue
Block a user