diff --git a/data/app.db b/data/app.db index 7146f0f..b775651 100644 Binary files a/data/app.db and b/data/app.db differ diff --git a/data/yokatlas.db b/data/yokatlas.db index 177f5e8..9516148 100644 Binary files a/data/yokatlas.db and b/data/yokatlas.db differ diff --git a/data/yokatlas.db-shm b/data/yokatlas.db-shm index 069b7fc..fe9ac28 100644 Binary files a/data/yokatlas.db-shm and b/data/yokatlas.db-shm differ diff --git a/data/yokatlas.db-wal b/data/yokatlas.db-wal index 21785dd..e69de29 100644 Binary files a/data/yokatlas.db-wal and b/data/yokatlas.db-wal differ diff --git a/next.config.ts b/next.config.ts index 5a55cdb..aacb5a6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // PPR: statik kabuk anında servis edilir, dinamik parçalar (UserNav vb.) + // Suspense sınırlarının içinde akar. Bkz. docs/cacheComponents.md. + cacheComponents: true, output: "standalone", serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"], async redirects() { diff --git a/scripts/db-index.ts b/scripts/db-index.ts new file mode 100644 index 0000000..ff5252a --- /dev/null +++ b/scripts/db-index.ts @@ -0,0 +1,36 @@ +// Sorgu yolundaki ifade indexi + WAL bakımı. Bir kez çalıştırılır +// (pnpm tsx scripts/db-index.ts); ingest/refresh sonrası da güvenle +// tekrar çalıştırılabilir (IF NOT EXISTS). +// +// src/lib/db.ts tüm aralık sorgularını COALESCE(sira2025, sira2024) +// üzerinden yapar; SQLite ifade indexleri METİNSEL eşleştiği için buradaki +// ifade oradaki EFEKTIF_SIRA sabitiyle birebir aynı olmalıdır. Bu index +// olmadan her sorgu tur-partisyonunu tarayıp temp B-tree ile sıralıyordu. + +import path from "node:path"; +import Database from "better-sqlite3"; + +const db = new Database(path.join(process.cwd(), "data", "yokatlas.db")); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif + ON programs (tur, onlisans, COALESCE(sira2025, sira2024)); +`); + +// Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i +// (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla. +db.pragma("wal_checkpoint(TRUNCATE)"); + +const plan = db + .prepare( + `EXPLAIN QUERY PLAN + SELECT id FROM programs + WHERE tur = ? AND onlisans = ? + AND COALESCE(sira2025, sira2024) IS NOT NULL + AND COALESCE(sira2025, sira2024) BETWEEN ? AND ? + ORDER BY COALESCE(sira2025, sira2024) ASC LIMIT 10`, + ) + .all("SAYISAL", 0, 1000, 50000); + +console.log("Sorgu planı:", JSON.stringify(plan, null, 2)); +db.close(); diff --git a/scripts/refresh.ts b/scripts/refresh.ts index 683f3da..d6b2ee0 100644 --- a/scripts/refresh.ts +++ b/scripts/refresh.ts @@ -119,6 +119,14 @@ async function main() { `); } + // Sorgu yolunun kullandığı ifade indexi; ifade src/lib/db.ts EFEKTIF_SIRA + // ile birebir aynı olmalı (bkz. scripts/db-index.ts). sira2025 kolonu + // yukarıda garanti edildikten sonra kurulur. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif + ON programs (tur, onlisans, COALESCE(sira2025, sira2024)); + `); + const update = db.prepare(` UPDATE programs SET sira2025 = ?, puan2025 = ?, kontenjan2025 = ?, yerlesen2025 = ? WHERE id = ? diff --git a/src/app/globals.css b/src/app/globals.css index 01a12f8..c5c418e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -162,6 +162,38 @@ } } +/* Landing bölümlerinin görünüme girerken belirmesi (bkz. landing/reveal.tsx). + İçerik SSR'da GÖRÜNÜR; gizleme yalnızca script çalışan tarayıcılarda ve + IO sınıfı ekleyene kadar geçerli — JS'siz istemcide sayfa asla boş kalmaz. */ +@media (scripting: enabled) { + .kt-reveal { + opacity: 0; + translate: 0 var(--reveal-y, 28px); + transition: + opacity 0.55s cubic-bezier(0.21, 0.47, 0.32, 0.98), + translate 0.55s cubic-bezier(0.21, 0.47, 0.32, 0.98); + /* Emniyet: hydration hiç gelmezse 3 sn sonra içerik yine de belirir */ + animation: kt-reveal-emniyet 0.5s ease-out 3s forwards; + } + .kt-reveal.kt-reveal-in { + opacity: 1; + translate: 0 0; + } + @media (prefers-reduced-motion: reduce) { + .kt-reveal { + translate: 0 0; + transition: opacity 0.25s ease-out; + } + } +} + +@keyframes kt-reveal-emniyet { + to { + opacity: 1; + translate: 0 0; + } +} + @layer base { * { @apply border-border outline-ring/50; @@ -180,11 +212,7 @@ } html { @apply font-sans; - scroll-behavior: smooth; - } - @media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } + /* scroll-behavior: smooth KULLANMA — Lenis (ScrollFlow, anchors: true) + kaydırmayı zaten yumuşatıyor; ikisi birlikte çift yumuşatma yapar. */ } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 25fa2ba..930699a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,7 @@ import localFont from "next/font/local"; import { Toaster } from "@/components/ui/sonner"; import { SiteHeader } from "@/components/site-header"; import { SiteTopBanner } from "@/components/site-top-banner"; -import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi"; +import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy"; import { HeaderGate } from "@/components/header-gate"; import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41"; import { DevPanel } from "@/components/dev/dev-panel"; @@ -31,6 +31,8 @@ const clarityCity = localFont({ const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], + // Yalnızca markdown/kod bloklarında kullanılıyor; landing'de preload israfı + preload: false, }); const bricolage = Bricolage_Grotesque({ @@ -81,7 +83,7 @@ export default function RootLayout({ {children} {/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */} - + {/* Dev süperadmin paneli — prod build'de hiç render edilmez */} {process.env.NODE_ENV !== "production" ? : null} diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx index 6134cc0..6e22b1c 100644 --- a/src/app/listem/page.tsx +++ b/src/app/listem/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { redirect } from "next/navigation"; import { AlertTriangle, @@ -27,7 +27,7 @@ import { RaporListesi } from "@/components/rapor-listesi"; import { ViewportPortal } from "@/components/viewport-portal"; import { PagePixelDivider } from "@/components/pixel-decor"; import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske"; -import { SohbetClient } from "./sohbet-client"; +import { SohbetClient, type Mesaj } from "./sohbet-client"; import { RevizyonKutusu } from "./revizyon-kutusu"; export const metadata: Metadata = { @@ -43,18 +43,32 @@ export default async function ListemPage({ }: { searchParams: Promise<{ acildi?: string }>; }) { - await verifySession("/listem"); - const user = await getCurrentUser(); + const session = await verifySession("/listem"); + // Kullanıcı satırı, ?acildi parametresi ve rapor birbirinden bağımsız — + // seri await zinciri yerine tek turda çöz. + const [user, { acildi }, satir, sohbetGecmisi] = await Promise.all([ + getCurrentUser(), + searchParams, + appDb.query.reports.findFirst({ + where: eq(schema.reports.userId, session.user.id), + }), + // Sohbet geçmişi burada gelirse client mount sonrası /api/soru turu atmaz + appDb + .select({ + id: schema.chatMessages.id, + role: schema.chatMessages.role, + content: schema.chatMessages.content, + }) + .from(schema.chatMessages) + .where(eq(schema.chatMessages.userId, session.user.id)) + .orderBy(asc(schema.chatMessages.createdAt)) + .limit(100), + ]); if (!user) redirect("/giris?callback=/listem"); // ?acildi=1: ödeme dönüşü — kilidin kalktığı tek seferlik açılış deneyimi - const { acildi } = await searchParams; const kilitAcilisi = Boolean(user.hasPaket && acildi === "1"); - const satir = await appDb.query.reports.findFirst({ - where: eq(schema.reports.userId, user.id), - }); - // Liste yoksa önce sihirbazdan oluşturulmalı if (!satir?.result) { return ( @@ -239,7 +253,10 @@ export default async function ListemPage({
{user.hasPaket ? ( - + ) : (
@@ -317,6 +334,7 @@ export default async function ListemPage({ tattırır (kalan kredi biterse composer paket CTA'sına döner). */}
diff --git a/src/app/listem/sohbet-client.tsx b/src/app/listem/sohbet-client.tsx index e26193f..70e06e7 100644 --- a/src/app/listem/sohbet-client.tsx +++ b/src/app/listem/sohbet-client.tsx @@ -23,7 +23,7 @@ import { ScrollButton } from "@/components/ui/scroll-button"; import { DotsLoader } from "@/components/ui/loader"; import { Skeleton } from "@/components/ui/skeleton"; -interface Mesaj { +export interface Mesaj { id: string; role: "user" | "assistant"; content: string; @@ -42,19 +42,25 @@ const ORNEK_SORULAR = [ */ export function SohbetClient({ baslangicKredi, + initialMesajlar, className, }: { baslangicKredi: number; + /** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */ + initialMesajlar?: Mesaj[]; /** Boyutlandırma override'ı (ör. alt popup'ta h-full) */ className?: string; }) { - const [mesajlar, setMesajlar] = useState([]); + const [mesajlar, setMesajlar] = useState(initialMesajlar ?? []); const [girdi, setGirdi] = useState(""); const [bekliyor, setBekliyor] = useState(false); - const [yuklendi, setYuklendi] = useState(false); + const [yuklendi, setYuklendi] = useState(initialMesajlar != null); const [kredi, setKredi] = useState(baslangicKredi); useEffect(() => { + // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; + // popup gibi geç mount olan yerlerde eski davranış sürer. + if (initialMesajlar != null) return; fetch("/api/soru") .then((res) => (res.ok ? res.json() : null)) .then((data) => { @@ -63,7 +69,7 @@ export function SohbetClient({ }) .catch(() => {}) .finally(() => setYuklendi(true)); - }, []); + }, [initialMesajlar]); async function gonder(metin: string) { const mesaj = metin.trim(); diff --git a/src/app/odeme/sonuc/loading.tsx b/src/app/odeme/sonuc/loading.tsx new file mode 100644 index 0000000..66aaee5 --- /dev/null +++ b/src/app/odeme/sonuc/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +// /odeme/sonuc iyzico doğrulaması bekler — ortalanmış sonuç kartı iskeleti. +export default function Loading() { + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/src/app/paket/loading.tsx b/src/app/paket/loading.tsx new file mode 100644 index 0000000..4c967c4 --- /dev/null +++ b/src/app/paket/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +// /paket dinamik (kullanıcı kredisi) — statik kabuk için sayfa geometrisine +// uygun iskelet: ortalanmış başlık bloğu + paket kartı şeridi. +export default function Loading() { + return ( +
+
+ + + + +
+
+ + +
+
+ ); +} diff --git a/src/app/paket/page.tsx b/src/app/paket/page.tsx index 627e862..71f112b 100644 --- a/src/app/paket/page.tsx +++ b/src/app/paket/page.tsx @@ -47,8 +47,7 @@ export default async function PaketPage({ }: { searchParams: Promise<{ hata?: string }>; }) { - const u = await getCurrentUser(); - const { hata } = await searchParams; + const [u, { hata }] = await Promise.all([getCurrentUser(), searchParams]); // Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner const odemeHatasi = hata === "token" || hata === "siparis"; diff --git a/src/app/rapor/yazdir/loading.tsx b/src/app/rapor/yazdir/loading.tsx new file mode 100644 index 0000000..87072d2 --- /dev/null +++ b/src/app/rapor/yazdir/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +// /rapor/yazdir rapor sorgusunu bekler — belge düzeninde iskelet. +export default function Loading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/src/app/rapor/yazdir/page.tsx b/src/app/rapor/yazdir/page.tsx index 504174f..8071219 100644 --- a/src/app/rapor/yazdir/page.tsx +++ b/src/app/rapor/yazdir/page.tsx @@ -20,14 +20,16 @@ const DILIM_LABEL: Record = { }; export default async function YazdirPage() { - await verifySession("/rapor/yazdir"); - const user = await getCurrentUser(); + const session = await verifySession("/rapor/yazdir"); + // Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz + const [user, satir] = await Promise.all([ + getCurrentUser(), + appDb.query.reports.findFirst({ + where: eq(schema.reports.userId, session.user.id), + }), + ]); if (!user) redirect("/giris"); if (!user.hasPaket) redirect("/paket"); - - const satir = await appDb.query.reports.findFirst({ - where: eq(schema.reports.userId, user.id), - }); if (!satir?.result) redirect("/sonuc"); const rapor = satir.result as RaporSonuc; diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx index a096e38..4e0fb37 100644 --- a/src/app/sonuc/page.tsx +++ b/src/app/sonuc/page.tsx @@ -71,14 +71,18 @@ export default async function SonucPage({ ); } - // Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler + // Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler. + // Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı + // beklemeden paralel çalışır (seri DB zinciri kurma). const session = await getSession(); - const user = session ? await getCurrentUser() : null; - const raporSatiri = user - ? await appDb.query.reports.findFirst({ - where: eq(schema.reports.userId, user.id), - }) - : null; + const [user, raporSatiri] = session + ? await Promise.all([ + getCurrentUser(), + appDb.query.reports.findFirst({ + where: eq(schema.reports.userId, session.user.id), + }), + ]) + : [null, null]; const facetler = rankWindowFacets(sira, turKey); // AI'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli) diff --git a/src/app/sonuc/sihirbaz-modal.tsx b/src/app/sonuc/sihirbaz-modal.tsx index 9c3f445..c4dec6d 100644 --- a/src/app/sonuc/sihirbaz-modal.tsx +++ b/src/app/sonuc/sihirbaz-modal.tsx @@ -6,7 +6,7 @@ import { DialogDescription, DialogTitle, } from "@/components/ui/dialog"; -import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar"; +import { SihirbazAdimlarLazy } from "@/components/sihirbaz-adimlar-lazy"; import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazSecimleri } from "@/lib/sihirbaz"; @@ -40,7 +40,7 @@ export function SihirbazModal({ Sıralamana uygun 24 tercihlik listeni oluştur. - import("@/app/listem/sohbet-client").then((m) => m.SohbetClient), + { + ssr: false, + loading: () => ( +
+ Sohbet yükleniyor… +
+ ), + }, +); + export function SohbetPopup({ kredi }: { kredi: number }) { const [acik, setAcik] = useState(false); const azMotion = useReducedMotion(); diff --git a/src/components/hero-form.tsx b/src/components/hero-form.tsx index 2619cd9..68c5a08 100644 --- a/src/components/hero-form.tsx +++ b/src/components/hero-form.tsx @@ -11,7 +11,10 @@ import { DialogDescription, DialogTitle, } from "@/components/ui/dialog"; -import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar"; +import { + SihirbazAdimlarLazy, + preloadSihirbazAdimlar, +} from "@/components/sihirbaz-adimlar-lazy"; import { authClient } from "@/lib/auth-client"; import type { SihirbazFacetleri } from "@/lib/db"; import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz"; @@ -33,9 +36,8 @@ export function HeroForm() { const [yukleniyor, setYukleniyor] = useState(false); const [modalAcik, setModalAcik] = useState(false); const [hata, setHata] = useState(null); + const [girisli, setGirisli] = useState(false); const router = useRouter(); - const { data: oturum } = authClient.useSession(); - const girisli = Boolean(oturum?.user); // Yazarken binlik ayracıyla biçimle (85000 → 85.000) function siralamaDegisti(ham: string) { @@ -55,9 +57,15 @@ export function HeroForm() { } setYukleniyor(true); try { - const res = await fetch(`/api/facetler?sira=${value}&tur=${tur}`); + // Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür: + // landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır). + const [res, oturum] = await Promise.all([ + fetch(`/api/facetler?sira=${value}&tur=${tur}`), + authClient.getSession().catch(() => null), + ]); if (!res.ok) throw new Error(); const veri = (await res.json()) as { facetler: SihirbazFacetleri }; + setGirisli(Boolean(oturum?.data?.user)); setSira(value); setFacetler(veri.facetler); setModalAcik(true); @@ -127,6 +135,7 @@ export function HeroForm() { placeholder="YKS başarı sıralaman (ör. 85.000)" value={siralama} onChange={(e) => siralamaDegisti(e.target.value)} + onFocus={preloadSihirbazAdimlar} aria-invalid={hata ? true : undefined} aria-describedby={hata ? "siralama-hata" : undefined} // flex-1 yalnızca yatay dizilimde (sm+): mobilde form flex-col olduğu @@ -162,7 +171,7 @@ export function HeroForm() { Sıralamana uygun seçimlerini yap, tercih planına geç. {facetler ? ( - (null); + const [gorunur, setGorunur] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || typeof IntersectionObserver === "undefined") { + setGorunur(true); + return; + } + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setGorunur(true); + io.disconnect(); + } + }, + { rootMargin: "0px 0px -80px 0px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, []); + return ( - {children} - +
); } diff --git a/src/components/manuel-liste/liste-cekmecesi-lazy.tsx b/src/components/manuel-liste/liste-cekmecesi-lazy.tsx new file mode 100644 index 0000000..0946dc6 --- /dev/null +++ b/src/components/manuel-liste/liste-cekmecesi-lazy.tsx @@ -0,0 +1,12 @@ +"use client"; + +// Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline +// bundle'a girmemesi için ssr'siz dynamic import (layout server component +// olduğundan bu sarmalayıcı gerekli). + +import dynamic from "next/dynamic"; + +export const ListeCekmecesiLazy = dynamic( + () => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi), + { ssr: false }, +); diff --git a/src/components/scroll-flow.tsx b/src/components/scroll-flow.tsx index e878cf9..7ced048 100644 --- a/src/components/scroll-flow.tsx +++ b/src/components/scroll-flow.tsx @@ -58,13 +58,18 @@ export function ScrollFlow({ return `skewY(${v}deg)`; }); - if (reduceMotion) { - return
{children}
; - } - + // Hareket azaltılmışsa da AYNI ağaç render edilir (server/client farklı + // ağaç hydration uyumsuzluğu yaratıyordu); efektler koşullu kapatılır: + // Lenis tekerlek yumuşatması kapalı, skew transform'u hiç bağlanmaz. return ( - - + + {children} diff --git a/src/components/sihirbaz-adimlar-lazy.tsx b/src/components/sihirbaz-adimlar-lazy.tsx new file mode 100644 index 0000000..8a310ee --- /dev/null +++ b/src/components/sihirbaz-adimlar-lazy.tsx @@ -0,0 +1,28 @@ +"use client"; + +// SihirbazAdimlar, turkey-map-react'in ~235 KB'lık il geometrisini taşır. +// Modal kapalıyken bu yükün ilk sayfa bundle'ına girmemesi için tek noktadan +// dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır. + +import dynamic from "next/dynamic"; + +export const SihirbazAdimlarLazy = dynamic( + () => + import("@/components/sihirbaz-adimlar").then((m) => m.SihirbazAdimlar), + { + ssr: false, + loading: () => ( +
+ Sihirbaz hazırlanıyor… +
+ ), + }, +); + +/** Kullanıcı forma dokunduğu anda chunk'ı arka planda indir. */ +export function preloadSihirbazAdimlar() { + void import("@/components/sihirbaz-adimlar"); +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 620b380..a31869c 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -1,6 +1,7 @@ +import { Suspense } from "react"; import Image from "next/image"; import Link from "next/link"; -import { UserNav } from "@/components/user-nav"; +import { UserNav, UserNavFallback } from "@/components/user-nav"; const navLinks = [ { href: "/#nasil-calisir", label: "Nasıl çalışır?" }, @@ -42,7 +43,10 @@ export function SiteHeader() { ))} - + {/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */} + }> + + ); diff --git a/src/components/ui/code-block.tsx b/src/components/ui/code-block.tsx index 21e3365..1b529cb 100644 --- a/src/components/ui/code-block.tsx +++ b/src/components/ui/code-block.tsx @@ -2,7 +2,6 @@ import { cn } from "@/lib/utils" import React, { useEffect, useState } from "react" -import { codeToHtml } from "shiki" export type CodeBlockProps = { children?: React.ReactNode @@ -47,6 +46,9 @@ function CodeBlockCode({ return } + // Shiki ~380 KB: yalnızca gerçekten bir kod bloğu çizilirken indir; + // o ana dek aşağıdaki düz
 fallback'i görünür.
+      const { codeToHtml } = await import("shiki")
       const html = await codeToHtml(code, { lang: language, theme })
       setHighlightedHtml(html)
     }
diff --git a/src/components/use-pixel-heat.ts b/src/components/use-pixel-heat.ts
index 781405a..ee020d8 100644
--- a/src/components/use-pixel-heat.ts
+++ b/src/components/use-pixel-heat.ts
@@ -8,6 +8,11 @@
  * SVG katmanları çoğunlukla pointer-events-none olduğundan imleç window
  * üzerinden izlenir ve viewBox koordinatına çevrilir. Beklenen veri
  * öznitelikleri: data-px, data-cx, data-cy, data-op (taban opaklık).
+ *
+ * Landing'de 16 örnek yaşar: her örneğin kendi window dinleyicisi yerine
+ * modül seviyesinde TEK pointermove dinleyicisi + rAF birleştirmesi kullanılır;
+ * kare başına en fazla bir tarama yapılır, imleçten uzak örnekler hiç
+ * piksel iterasyonuna girmez.
  */
 
 import { useEffect, type RefObject } from "react";
@@ -26,6 +31,99 @@ type HeatTarget = {
   baseOpacity: number;
 };
 
+type HeatInstance = {
+  svg: SVGSVGElement;
+  viewW: number;
+  radius: number;
+  baseColor: string;
+  pixels: HeatTarget[];
+  hot: Set;
+  controls: Map;
+};
+
+const instances = new Set();
+let sonOlay: PointerEvent | null = null;
+let rafId = 0;
+
+function calistir(
+  inst: HeatInstance,
+  el: SVGRectElement,
+  to: { fill: string; fillOpacity: number },
+  duration: number,
+) {
+  inst.controls.get(el)?.stop();
+  inst.controls.set(el, animate(el, to, { duration, ease: "easeOut" }));
+}
+
+function tara() {
+  rafId = 0;
+  const e = sonOlay;
+  if (!e) return;
+  for (const inst of instances) {
+    const rect = inst.svg.getBoundingClientRect();
+    if (rect.width === 0) continue;
+    const scale = rect.width / inst.viewW;
+    const pad = inst.radius * scale;
+    // İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa
+    // bu örnek için hiç hesap yapma
+    if (
+      inst.hot.size === 0 &&
+      (e.clientX < rect.left - pad ||
+        e.clientX > rect.right + pad ||
+        e.clientY < rect.top - pad ||
+        e.clientY > rect.bottom + pad)
+    ) {
+      continue;
+    }
+    const x = (e.clientX - rect.left) / scale;
+    const y = (e.clientY - rect.top) / scale;
+    for (const p of inst.pixels) {
+      const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
+      const isHot = d2 <= inst.radius * inst.radius;
+      if (isHot && !inst.hot.has(p.el)) {
+        inst.hot.add(p.el);
+        calistir(
+          inst,
+          p.el,
+          { fill: HOT_COLOR, fillOpacity: HOT_OPACITY },
+          HEAT_DURATION,
+        );
+      } else if (!isHot && inst.hot.has(p.el)) {
+        inst.hot.delete(p.el);
+        calistir(
+          inst,
+          p.el,
+          { fill: inst.baseColor, fillOpacity: p.baseOpacity },
+          COOL_DURATION,
+        );
+      }
+    }
+  }
+}
+
+function onMove(e: PointerEvent) {
+  sonOlay = e;
+  if (!rafId) rafId = requestAnimationFrame(tara);
+}
+
+function kaydol(inst: HeatInstance) {
+  if (instances.size === 0) {
+    window.addEventListener("pointermove", onMove, { passive: true });
+  }
+  instances.add(inst);
+}
+
+function ayril(inst: HeatInstance) {
+  instances.delete(inst);
+  inst.controls.forEach((c) => c.stop());
+  if (instances.size === 0) {
+    window.removeEventListener("pointermove", onMove);
+    if (rafId) cancelAnimationFrame(rafId);
+    rafId = 0;
+    sonOlay = null;
+  }
+}
+
 export function usePixelHeat(
   svgRef: RefObject,
   {
@@ -42,7 +140,6 @@ export function usePixelHeat(
     const viewW = svg.viewBox.baseVal.width;
     if (!viewW) return;
 
-    const baseColor = getComputedStyle(svg).color;
     const pixels: HeatTarget[] = Array.from(
       svg.querySelectorAll("[data-px]"),
     ).map((el) => ({
@@ -53,61 +150,16 @@ export function usePixelHeat(
     }));
     if (pixels.length === 0) return;
 
-    const hot = new Set();
-    const controls = new Map();
-
-    const run = (
-      el: SVGRectElement,
-      to: { fill: string; fillOpacity: number },
-      duration: number,
-    ) => {
-      controls.get(el)?.stop();
-      controls.set(el, animate(el, to, { duration, ease: "easeOut" }));
-    };
-
-    const onMove = (e: PointerEvent) => {
-      const rect = svg.getBoundingClientRect();
-      if (rect.width === 0) return;
-      const scale = rect.width / viewW;
-      const pad = radius * scale;
-      // İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa
-      // hiç hesap yapma (sayfada birden çok örnek dinlediği için önemli)
-      if (
-        hot.size === 0 &&
-        (e.clientX < rect.left - pad ||
-          e.clientX > rect.right + pad ||
-          e.clientY < rect.top - pad ||
-          e.clientY > rect.bottom + pad)
-      ) {
-        return;
-      }
-      const x = (e.clientX - rect.left) / scale;
-      const y = (e.clientY - rect.top) / scale;
-      for (const p of pixels) {
-        const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
-        const isHot = d2 <= radius * radius;
-        if (isHot && !hot.has(p.el)) {
-          hot.add(p.el);
-          run(
-            p.el,
-            { fill: HOT_COLOR, fillOpacity: HOT_OPACITY },
-            HEAT_DURATION,
-          );
-        } else if (!isHot && hot.has(p.el)) {
-          hot.delete(p.el);
-          run(
-            p.el,
-            { fill: baseColor, fillOpacity: p.baseOpacity },
-            COOL_DURATION,
-          );
-        }
-      }
-    };
-
-    window.addEventListener("pointermove", onMove, { passive: true });
-    return () => {
-      window.removeEventListener("pointermove", onMove);
-      controls.forEach((c) => c.stop());
+    const inst: HeatInstance = {
+      svg,
+      viewW,
+      radius,
+      baseColor: getComputedStyle(svg).color,
+      pixels,
+      hot: new Set(),
+      controls: new Map(),
     };
+    kaydol(inst);
+    return () => ayril(inst);
   }, [svgRef, radius]);
 }
diff --git a/src/components/user-nav.tsx b/src/components/user-nav.tsx
index 78f9bd9..f5c55f6 100644
--- a/src/components/user-nav.tsx
+++ b/src/components/user-nav.tsx
@@ -5,6 +5,18 @@ import { Button } from "@/components/ui/button";
 import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
 import { SignOutButton } from "./sign-out-button";
 
+/**
+ * Suspense fallback'i: girişsiz görünümle aynı yükseklik/genişlikte sessiz
+ * bir pill — session sorgusu akarken header zıplamasın (CLS yok).
+ */
+export function UserNavFallback() {
+  return (
+    
+
+
+ ); +} + export async function UserNav() { const u = await getCurrentUser(); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6681449..57d8233 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -37,6 +37,12 @@ export const auth = betterAuth({ verification: schema.verification, }, }), + session: { + // Session'ı imzalı cookie'de önbelle: her istekte session tablosu + // sorgusu yapılmaz. Kredi/paket tazeliği getCurrentUser()'daki ayrı + // user sorgusuyla korunur (src/lib/session.ts). + cookieCache: { enabled: true, maxAge: 5 * 60 }, + }, user: { additionalFields: { creditBalance: { type: "number", defaultValue: 0, input: false },