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