Enhance application performance and user experience by enabling component caching in next.config.ts, optimizing database queries in various pages, and implementing lazy loading for heavy components. Updated styles for smoother transitions and improved visual clarity in multiple components, including globals.css and landing sections.
All checks were successful
Deploy / deploy (push) Successful in 2m30s

This commit is contained in:
bilalgursen
2026-08-01 14:00:22 +03:00
parent acb3b9d557
commit 569b9e5da2
29 changed files with 458 additions and 126 deletions

View File

@@ -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. */
}
}

View File

@@ -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}
</ScrollFlow>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesi />
<ListeCekmecesiLazy />
<Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}

View File

@@ -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({
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
{user.hasPaket ? (
<SohbetClient baslangicKredi={user.creditBalance} />
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
/>
) : (
<div className="flex flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
@@ -317,6 +334,7 @@ export default async function ListemPage({
tattırır (kalan kredi biterse composer paket CTA'sına döner). */}
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
className="h-[480px]"
/>
</div>

View File

@@ -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<Mesaj[]>([]);
const [mesajlar, setMesajlar] = useState<Mesaj[]>(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();

View File

@@ -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 (
<main
aria-live="polite"
className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16"
>
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Skeleton className="mx-auto size-12 rounded-full" />
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
<Skeleton className="mx-auto mt-3 h-5 w-full" />
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
</div>
</main>
);
}

23
src/app/paket/loading.tsx Normal file
View File

@@ -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 (
<main
aria-live="polite"
className="mx-auto w-full max-w-3xl flex-1 px-4 py-16"
>
<div className="flex flex-col items-center text-center">
<Skeleton className="h-6 w-56 rounded-full" />
<Skeleton className="mt-4 h-10 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<Skeleton className="mt-2 h-5 w-1/2 max-w-md" />
</div>
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]">
<Skeleton className="h-80 w-full rounded-2xl" />
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
</div>
</main>
);
}

View File

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

View File

@@ -0,0 +1,21 @@
import { Skeleton } from "@/components/ui/skeleton";
// /rapor/yazdir rapor sorgusunu bekler — belge düzeninde iskelet.
export default function Loading() {
return (
<main
aria-live="polite"
className="mx-auto w-full max-w-3xl bg-white p-8"
>
<div className="border-b-2 border-slate-200 pb-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-2 h-4 w-40" />
</div>
<div className="mt-6 space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
</main>
);
}

View File

@@ -20,14 +20,16 @@ const DILIM_LABEL: Record<string, string> = {
};
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;

View File

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

View File

@@ -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({
<DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur.
</DialogDescription>
<SihirbazAdimlar
<SihirbazAdimlarLazy
facetler={facetler}
baslangicSecimler={baslangicSecimler}
sonButonEtiketi={sonButonEtiketi}

View File

@@ -5,11 +5,25 @@
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SohbetClient } from "@/app/listem/sohbet-client";
import { ViewportPortal } from "@/components/viewport-portal";
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
// yüklensin — /sonuc ilk yükünü şişirmesin.
const SohbetClient = dynamic(
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-white text-sm text-slate-500 shadow-2xl">
Sohbet yükleniyor
</div>
),
},
);
export function SohbetPopup({ kredi }: { kredi: number }) {
const [acik, setAcik] = useState(false);
const azMotion = useReducedMotion();