Enhance error handling and user experience in AI-related features
Some checks failed
Deploy / deploy (push) Failing after 38m47s

- Introduced a mechanism to save partially generated responses in case of errors, ensuring users do not lose their input.
- Updated the RevizyonKutusu component to utilize a persistent request ID for retrying failed requests without double charging credits.
- Improved the Sayfalama component to support client-side pagination without changing the URL, enhancing user navigation.
- Added a timeout for AI generation requests to ensure credits are refunded in case of prolonged processing.
- Refactored various components for better clarity and responsiveness, including updates to the BolumProgramListesi and ListePaneli components.
This commit is contained in:
bilalgursen
2026-08-03 14:12:06 +03:00
parent c2aba25814
commit a5c2bcbc4d
13 changed files with 384 additions and 180 deletions

View File

@@ -247,9 +247,9 @@ export async function POST(request: NextRequest) {
const readable = new ReadableStream({
async start(controller) {
let tamamlandi = false;
let metin = "";
try {
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
let metin = "";
for await (const parca of sohbetAkisi({
system,
mesajlar,
@@ -269,7 +269,20 @@ export async function POST(request: NextRequest) {
controller.close();
} catch (err) {
if (!tamamlandi) {
// Hiç çıktı üretilmeden hata olduysa krediyi iade et
// Cevap tamamlanmadı: kredi iade edilir. Yarıda kalan metin varsa
// yine de kaydedilir ki kullanıcı ekranda gördüğü kısmı sayfa
// yenilenince kaybetmesin.
if (metin) {
try {
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId,
role: "assistant",
content: metin,
createdAt: new Date(),
});
} catch {}
}
await grantCredits({
userId,
delta: 1,

View File

@@ -4,7 +4,7 @@ import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { getBolumBySlug, uniSlugFromAd } from "@/lib/katalog";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { SAYFA_BOYU } from "@/components/sayfalama";
import { BolumProgramListesi } from "@/components/bolum-program-listesi";
import { SiteFooter } from "@/components/site-footer";
@@ -46,10 +46,6 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
];
const toplamSayfa = bolumToplamSayfa(tumProgramlar.length);
if (sayfa < 1 || sayfa > toplamSayfa) notFound();
const dilim = tumProgramlar.slice(
(sayfa - 1) * SAYFA_BOYU,
sayfa * SAYFA_BOYU,
);
const ilkSayfa = sayfa === 1;
const buYol = ilkSayfa ? `/bolum/${slug}` : `/bolum/${slug}/sayfa/${sayfa}`;
@@ -113,19 +109,15 @@ export function BolumIcerik({ slug, sayfa }: { slug: string; sayfa: number }) {
</div>
<BolumProgramListesi
programlar={dilim.map((program) => ({
programlar={tumProgramlar.map((program) => ({
...program,
universiteSlug: uniSlugFromAd(program.universite),
}))}
bazAd={bolum.ad}
tabanYol={`/bolum/${slug}`}
sayfa={sayfa}
/>
</section>
<Sayfalama
tabanYol={`/bolum/${slug}`}
sayfa={sayfa}
toplamSayfa={toplamSayfa}
/>
</main>
<SiteFooter />
</>

View File

@@ -29,6 +29,11 @@ import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import { ListemBosCta } from "./listem-bos-cta";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
// dolmalı ki kredi iadesi her zaman çalışabilsin.
export const maxDuration = 120;
export const metadata: Metadata = {
title: "Listem",
robots: { index: false, follow: false },

View File

@@ -4,7 +4,7 @@
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın.
import { useState } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
@@ -16,6 +16,10 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
const router = useRouter();
// Aynı deneme için sabit requestId: cevap kaybolan network hatasında retry
// çifte harcamaz. Sunucudan cevap gelince sıfırlanır (harcama ya tamamlandı
// ya iade edildi; sonraki deneme taze id ile başlamalı — bkz. sonuc/actions.ts).
const requestIdRef = useRef<string | null>(null);
if (kalanHak <= 0) {
return (
@@ -34,10 +38,12 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
async function revizeEt() {
setPending(true);
try {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
const sonuc = await listeRevize({
feedback: feedback.trim(),
requestId: crypto.randomUUID(),
requestId: requestIdRef.current,
});
requestIdRef.current = null;
if (sonuc.ok) {
olay("liste_revize", { sayfa: "listem" });
toast.success("Listen yeniden kuruldu.");

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
@@ -66,6 +67,7 @@ export function SohbetClient({
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
const [kredi, setKredi] = useState(baslangicKredi);
const router = useRouter();
useEffect(() => {
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
@@ -110,6 +112,8 @@ export function SohbetClient({
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
// Mesaj işlenmedi; kullanıcının yazdığı soru kaybolmasın
setGirdi(mesaj);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0);
@@ -136,6 +140,9 @@ export function SohbetClient({
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
// Header'daki kredi pill'i layout'ta server-render edilir ve client
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
@@ -315,7 +322,17 @@ export function SohbetClient({
</Button>
</div>
) : (
<PromptInput
<>
{kredi <= 3 ? (
<p className="mb-2 px-1 text-xs text-amber-700">
Son {kredi} kredin bitse de listen ve sohbetin kaybolmaz;{" "}
<Link href="/paket" className="underline hover:text-amber-800">
kredi yükleyip
</Link>{" "}
kaldığın yerden devam edersin.
</p>
) : null}
<PromptInput
value={girdi}
onValueChange={setGirdi}
onSubmit={() => gonder(girdi)}
@@ -342,6 +359,7 @@ export function SohbetClient({
</PromptInputAction>
</PromptInputActions>
</PromptInput>
</>
)}
</div>
</section>

View File

@@ -164,11 +164,20 @@ export default async function PaketPage({
{tl(URUNLER.topup.amountKurus)} TL.
</p>
<div className="mt-4">
{u ? (
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
yine maskeli liste görür. Satın alma yalnızca paketliye
ık; paketsize durum dürüstçe açıklanır. */}
{u?.hasPaket ? (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
/>
) : u ? (
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
Ek kredi, kilitli analizleri açmaz o yüzden önce paket
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
bitince buradan yükleyebilirsin.
</p>
) : (
<Button
asChild

View File

@@ -1,6 +1,6 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
@@ -41,6 +41,11 @@ function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
function aiHataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message;
// AbortSignal.timeout: DOMException "TimeoutError"; SDK'lar abort'u kendi
// hata sınıflarıyla sarabiliyor (ör. APIUserAbortError) — ada göre yakala.
if (err instanceof Error && /abort|timeout/i.test(err.name)) {
return "Liste üretimi bu sefer çok uzun sürdü ve durduruldu; harcanan kredin otomatik iade edildi. Tekrar dener misin?";
}
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj
console.error("[liste] Yapay Zeka anahtarı eksik — üretim başlatılamadı");
@@ -114,7 +119,23 @@ export async function listeOlustur(input: {
error: `Liste oluşturmak için ${RAPOR_KREDI} kredi gerekli.`,
};
}
// DUPLICATE: bu requestId zaten işlendi → mevcut raporu döndür
// DUPLICATE: bu requestId zaten işlendi. Önceki deneme başarısız olup
// iade edildiyse mevcut (eski) raporu yeniymiş gibi döndürme; taze bir
// requestId ile yeniden denemesini iste.
const iade = await appDb.query.creditLedger.findFirst({
where: and(
eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId),
),
});
if (iade) {
return {
ok: false,
code: "HATA",
error:
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
};
}
const mevcut = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
@@ -226,6 +247,36 @@ export async function listeRevize(input: {
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`,
};
}
// DUPLICATE: bu requestId zaten işlendi. İade varsa önceki deneme
// başarısızdı → taze bir denemeye yönlendir. Yoksa revizyon tamamlanmış
// ama cevap istemciye ulaşmamıştır → güncel raporu döndür.
const iade = await appDb.query.creditLedger.findFirst({
where: and(
eq(schema.creditLedger.reason, "refund"),
eq(schema.creditLedger.refId, input.requestId),
),
});
if (iade) {
return {
ok: false,
code: "HATA",
error:
"Önceki denemen başarısız olduğu için kredin iade edilmişti. Tekrar dener misin?",
};
}
const guncel = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
if (guncel?.result) {
return {
ok: true,
rapor: guncel.result as RaporSonuc,
params: guncel.params as RaporParams,
revisionCount: guncel.revisionCount,
kredi: user.creditBalance,
hasPaket: true,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
}

View File

@@ -2,7 +2,7 @@
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { FileText, Lock, RefreshCcw } from "lucide-react";
import { ArrowRight, FileText, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SohbetPopup } from "./sohbet-popup";
@@ -156,6 +156,8 @@ export function ListePaneli({
if (durum === "uretiliyor") return <UretimGovdesi />;
if (durum === "kredi-yetersiz") {
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
return (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
@@ -163,15 +165,19 @@ export function ListePaneli({
Kredin bu liste için yetmiyor
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Liste üretimi 3 kredi. Tercih Dönemi Paketi&apos;yle 60 kredi, tam
liste, PDF ve revizyon hakları birlikte gelir.
{hasPaket
? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin."
: "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Paketi aktive et</Link>
<Link href="/paket">
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
</section>
);

View File

@@ -18,6 +18,11 @@ import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli";
// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
// dolmalı ki kredi iadesi her zaman çalışabilsin.
export const maxDuration = 120;
export const metadata: Metadata = {
title: "Tercih Listeni Kur",
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama

View File

@@ -105,6 +105,8 @@ export function SihirbazBolumu({
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
// Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx)
const revizyonIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
@@ -228,9 +230,17 @@ export function SihirbazBolumu({
// Yapay Zeka çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
router.push("/listem");
// Header'daki kredi pill'i layout'ta server-render edilir; push layout'u
// yeniden çizmediği için düşen bakiye refresh ile güncellenir.
router.refresh();
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
// Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi;
// sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e
// takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap
// alınamayan network hatasında (catch) korunur — orada çifte harcama
// riski gerçek.
requestIdRef.current = null;
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") {
@@ -389,10 +399,14 @@ export function SihirbazBolumu({
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
if (!revizyonIdRef.current) {
revizyonIdRef.current = crypto.randomUUID();
}
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
requestId: revizyonIdRef.current,
});
revizyonIdRef.current = null;
if (sonuc.ok) {
olay("liste_revize", { sayfa: "sonuc" });
setRapor({

View File

@@ -1,10 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/lib/db";
import { trBaslikDuzeni } from "@/lib/slug";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -82,12 +83,22 @@ function SiralamaDugmesi({
export function BolumProgramListesi({
programlar,
bazAd,
tabanYol,
sayfa,
}: {
/** Bölümün TÜM programları — sıralama liste geneline uygulanır. */
programlar: BolumProgrami[];
bazAd: string;
tabanYol: string;
sayfa: number;
}) {
const [aktifAlan, setAktifAlan] = useState<SiralamaAlani | null>(null);
const [yon, setYon] = useState<SiralamaYonu>("artan");
// Sıralama aktifken URL değişmeden istemci tarafında gezilen sayfa
const [istemciSayfa, setIstemciSayfa] = useState(1);
const listeRef = useRef<HTMLDivElement>(null);
const toplamSayfa = Math.max(1, Math.ceil(programlar.length / SAYFA_BOYU));
const siraliProgramlar = useMemo(() => {
if (!aktifAlan) return programlar;
@@ -107,7 +118,18 @@ export function BolumProgramListesi({
.map(({ program }) => program);
}, [aktifAlan, programlar, yon]);
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
const gosterilenProgramlar = useMemo(
() =>
siraliProgramlar.slice(
(gosterilenSayfa - 1) * SAYFA_BOYU,
gosterilenSayfa * SAYFA_BOYU,
),
[siraliProgramlar, gosterilenSayfa],
);
function sirala(alan: SiralamaAlani) {
setIstemciSayfa(1);
if (aktifAlan === alan) {
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
return;
@@ -116,124 +138,140 @@ export function BolumProgramListesi({
setYon("artan");
}
return (
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Üniversite</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
<SiralamaDugmesi
alan="puan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
<SiralamaDugmesi
alan="kontenjan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="text-right">
<SiralamaDugmesi
alan="sira2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{siraliProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
const devlet = p.unitur === "DEVLET";
function istemciSayfaDegistir(n: number) {
setIstemciSayfa(n);
listeRef.current?.scrollIntoView({ block: "start" });
}
return (
<TableRow key={p.id}>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p.universiteSlug ? (
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
) : null}
<span className="min-w-0 flex-1">
return (
<>
<div
ref={listeRef}
className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white"
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Üniversite</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
<SiralamaDugmesi
alan="puan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
<SiralamaDugmesi
alan="kontenjan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="text-right">
<SiralamaDugmesi
alan="sira2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{gosterilenProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
const devlet = p.unitur === "DEVLET";
return (
<TableRow key={p.id}>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{uniAd}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{uniAd}
</span>
)}
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
) : null}
<span className="min-w-0 flex-1">
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
{uniAd}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{uniAd}
</span>
) : null}
<span className="truncate">
{p.il ? trBaslikDuzeni(p.il) : null}
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
)}
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
</span>
) : null}
<span className="truncate">
{p.il ? trBaslikDuzeni(p.il) : null}
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={p} />
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: ""}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={p} />
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<Sayfalama
tabanYol={tabanYol}
sayfa={gosterilenSayfa}
toplamSayfa={toplamSayfa}
onSayfa={aktifAlan ? istemciSayfaDegistir : undefined}
/>
</>
);
}

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -8,23 +9,56 @@ export const SAYFA_BOYU = 50;
* Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in
* kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek
* <Link>, Google her sayfayı ayrı statik URL olarak tarar.
*
* `onSayfa` verilirse Link yerine aynı görünümde butonlar render edilir —
* istemci tarafında sıralanmış listelerin sayfalaması URL değiştirmeden
* bu geri çağrıyla ilerler.
*/
export function Sayfalama({
tabanYol,
sayfa,
toplamSayfa,
sorgu,
onSayfa,
}: {
tabanYol: string;
sayfa: number;
toplamSayfa: number;
sorgu?: string;
onSayfa?: (n: number) => void;
}) {
if (toplamSayfa <= 1) return null;
const yol = (n: number) => {
const path = n === 1 ? tabanYol : `${tabanYol}/sayfa/${n}`;
return sorgu ? `${path}?${sorgu}` : path;
};
const gecis = (
n: number,
className: string,
icerik: ReactNode,
ozellikler?: { rel?: string; aktif?: boolean },
) =>
onSayfa ? (
<button
key={n}
type="button"
onClick={() => onSayfa(n)}
aria-current={ozellikler?.aktif ? "page" : undefined}
className={`${className} cursor-pointer`}
>
{icerik}
</button>
) : (
<Link
key={n}
href={yol(n)}
rel={ozellikler?.rel}
aria-current={ozellikler?.aktif ? "page" : undefined}
className={className}
>
{icerik}
</Link>
);
// 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son
const numaralar: (number | "...")[] = [];
@@ -49,16 +83,17 @@ export function Sayfalama({
aria-label="Sayfalama"
className="mt-6 flex flex-wrap items-center justify-center gap-1.5"
>
{sayfa > 1 ? (
<Link
href={yol(sayfa - 1)}
rel="prev"
className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
>
<ChevronLeft className="size-4" aria-hidden />
Önceki
</Link>
) : null}
{sayfa > 1
? gecis(
sayfa - 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
<ChevronLeft className="size-4" aria-hidden />
Önceki
</>,
{ rel: "prev" },
)
: null}
{numaralar.map((n, i) =>
n === "..." ? (
<span
@@ -69,30 +104,27 @@ export function Sayfalama({
</span>
) : (
<Link
key={n}
href={yol(n)}
aria-current={n === sayfa ? "page" : undefined}
className={
n === sayfa
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
}
>
{n}
</Link>
gecis(
n,
n === sayfa
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
n,
{ aktif: n === sayfa },
)
),
)}
{sayfa < toplamSayfa ? (
<Link
href={yol(sayfa + 1)}
rel="next"
className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
>
Sonraki
<ChevronRight className="size-4" aria-hidden />
</Link>
) : null}
{sayfa < toplamSayfa
? gecis(
sayfa + 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
Sonraki
<ChevronRight className="size-4" aria-hidden />
</>,
{ rel: "next" },
)
: null}
</nav>
);
}

View File

@@ -61,9 +61,17 @@ async function geciciHatadaTekrarla<T>(
throw sonHata;
}
/**
* Tek yapılandırılmış üretim çağrısının üst süre sınırı. Süre, çağıranın
* (server action) platform limitinden önce dolmalı ki hata iade kodunun
* çalışabildiği pencerede fırlasın — fonksiyon dışarıdan öldürülürse
* harcanan kredi iade edilemiyor.
*/
const URETIM_ZAMAN_ASIMI_MS = 50_000;
/**
* Şemaya uyan tek seferlik çıktı üretir. Şemaya uymayan yanıtta null döner
* (çağıran taraf yeniden dener).
* (çağıran taraf yeniden dener). Zaman aşımında abort hatası fırlar.
*/
export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
system: string;
@@ -71,12 +79,16 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
sema: T;
maxTokens: number;
}): Promise<z.infer<T> | null> {
// Tek sinyal tüm iç denemeleri kapsar: süre dolunca bekleyen retry'lar da
// anında düşer, toplam süre sınırın üstüne çıkamaz.
const sinyal = AbortSignal.timeout(URETIM_ZAMAN_ASIMI_MS);
if (saglayici() === "gemini") {
const yanit = await geciciHatadaTekrarla(() =>
getGemini().models.generateContent({
model: raporModeli(),
contents: opts.kullanici,
config: {
abortSignal: sinyal,
systemInstruction: opts.system,
maxOutputTokens: opts.maxTokens,
responseMimeType: "application/json",
@@ -95,14 +107,17 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
}
}
const yanit = await getAnthropic().messages.parse({
model: raporModeli(),
max_tokens: opts.maxTokens,
thinking: { type: "adaptive" },
system: opts.system,
messages: [{ role: "user", content: opts.kullanici }],
output_config: { format: zodOutputFormat(opts.sema) },
});
const yanit = await getAnthropic().messages.parse(
{
model: raporModeli(),
max_tokens: opts.maxTokens,
thinking: { type: "adaptive" },
system: opts.system,
messages: [{ role: "user", content: opts.kullanici }],
output_config: { format: zodOutputFormat(opts.sema) },
},
{ signal: sinyal },
);
return yanit.parsed_output ?? null;
}