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({ const readable = new ReadableStream({
async start(controller) { async start(controller) {
let tamamlandi = false; let tamamlandi = false;
let metin = "";
try { try {
// Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır // Sağlayıcıya bağlanma hatası da ilk parçada burada yakalanır
let metin = "";
for await (const parca of sohbetAkisi({ for await (const parca of sohbetAkisi({
system, system,
mesajlar, mesajlar,
@@ -269,7 +269,20 @@ export async function POST(request: NextRequest) {
controller.close(); controller.close();
} catch (err) { } catch (err) {
if (!tamamlandi) { 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({ await grantCredits({
userId, userId,
delta: 1, delta: 1,

View File

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

View File

@@ -29,6 +29,11 @@ import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu"; import { RevizyonKutusu } from "./revizyon-kutusu";
import { ListemBosCta } from "./listem-bos-cta"; 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 = { export const metadata: Metadata = {
title: "Listem", title: "Listem",
robots: { index: false, follow: false }, robots: { index: false, follow: false },

View File

@@ -4,7 +4,7 @@
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki // listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın. // 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 { useRouter } from "next/navigation";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -16,6 +16,10 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
const [feedback, setFeedback] = useState(""); const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const router = useRouter(); 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) { if (kalanHak <= 0) {
return ( return (
@@ -34,10 +38,12 @@ export function RevizyonKutusu({ kalanHak }: { kalanHak: number }) {
async function revizeEt() { async function revizeEt() {
setPending(true); setPending(true);
try { try {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
const sonuc = await listeRevize({ const sonuc = await listeRevize({
feedback: feedback.trim(), feedback: feedback.trim(),
requestId: crypto.randomUUID(), requestId: requestIdRef.current,
}); });
requestIdRef.current = null;
if (sonuc.ok) { if (sonuc.ok) {
olay("liste_revize", { sayfa: "listem" }); olay("liste_revize", { sayfa: "listem" });
toast.success("Listen yeniden kuruldu."); toast.success("Listen yeniden kuruldu.");

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react"; import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -66,6 +67,7 @@ export function SohbetClient({
const [bekliyor, setBekliyor] = useState(false); const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(initialMesajlar != null); const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
const [kredi, setKredi] = useState(baslangicKredi); const [kredi, setKredi] = useState(baslangicKredi);
const router = useRouter();
useEffect(() => { useEffect(() => {
// Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma; // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
@@ -110,6 +112,8 @@ export function SohbetClient({
setMesajlar((m) => setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId), 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) { if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin."); toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
setKredi(0); setKredi(0);
@@ -136,6 +140,9 @@ export function SohbetClient({
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
// harcamasını görmez; bakiye orada da güncellensin.
router.refresh();
} catch { } catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin."); toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally { } finally {
@@ -315,7 +322,17 @@ export function SohbetClient({
</Button> </Button>
</div> </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} value={girdi}
onValueChange={setGirdi} onValueChange={setGirdi}
onSubmit={() => gonder(girdi)} onSubmit={() => gonder(girdi)}
@@ -342,6 +359,7 @@ export function SohbetClient({
</PromptInputAction> </PromptInputAction>
</PromptInputActions> </PromptInputActions>
</PromptInput> </PromptInput>
</>
)} )}
</div> </div>
</section> </section>

View File

@@ -164,11 +164,20 @@ export default async function PaketPage({
{tl(URUNLER.topup.amountKurus)} TL. {tl(URUNLER.topup.amountKurus)} TL.
</p> </p>
<div className="mt-4"> <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 <SatinAlForm
urun="topup" urun="topup"
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`} 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 <Button
asChild asChild

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { getSession, getCurrentUser } from "@/lib/session"; import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb"; import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db"; 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 { function aiHataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message; 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") { if (err instanceof Error && err.message === "AI_KEY_MISSING") {
// Yapılandırma detayı log'a; kullanıcıya altyapı sızdırmayan mesaj // 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ı"); 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.`, 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({ const mevcut = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id), where: eq(schema.reports.userId, user.id),
}); });
@@ -226,6 +247,36 @@ export async function listeRevize(input: {
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`, 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ı." }; return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
} }

View File

@@ -2,7 +2,7 @@
import { useMemo, useEffect, useRef, useState } from "react"; import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link"; 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 { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SohbetPopup } from "./sohbet-popup"; import { SohbetPopup } from "./sohbet-popup";
@@ -156,6 +156,8 @@ export function ListePaneli({
if (durum === "uretiliyor") return <UretimGovdesi />; if (durum === "uretiliyor") return <UretimGovdesi />;
if (durum === "kredi-yetersiz") { 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 ( return (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12"> <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 /> <Lock className="mx-auto size-8 text-slate-400" aria-hidden />
@@ -163,15 +165,19 @@ export function ListePaneli({
Kredin bu liste için yetmiyor Kredin bu liste için yetmiyor
</h2> </h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600"> <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 {hasPaket
liste, PDF ve revizyon hakları birlikte gelir. ? "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> </p>
<Button <Button
asChild asChild
size="lg" size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600" 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> </Button>
</section> </section>
); );

View File

@@ -18,6 +18,11 @@ import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli"; 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 = { export const metadata: Metadata = {
title: "Tercih Listeni Kur", title: "Tercih Listeni Kur",
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama // 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) // Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null); 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 otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null); const bolumRef = useRef<HTMLDivElement>(null);
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi // 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, // Yapay Zeka çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar. // liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
router.push("/listem"); 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; 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") { if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz"); setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") { } else if (sonuc.code === "AUTH") {
@@ -389,10 +399,14 @@ export function SihirbazBolumu({
sira={sira} sira={sira}
acilis={acilis} acilis={acilis}
onRevize={async (feedback) => { onRevize={async (feedback) => {
if (!revizyonIdRef.current) {
revizyonIdRef.current = crypto.randomUUID();
}
const sonuc = await listeRevize({ const sonuc = await listeRevize({
feedback, feedback,
requestId: crypto.randomUUID(), requestId: revizyonIdRef.current,
}); });
revizyonIdRef.current = null;
if (sonuc.ok) { if (sonuc.ok) {
olay("liste_revize", { sayfa: "sonuc" }); olay("liste_revize", { sayfa: "sonuc" });
setRapor({ setRapor({

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { 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 "@/lib/db"; import type { Program } from "@/lib/db";
import { trBaslikDuzeni } from "@/lib/slug"; import { trBaslikDuzeni } from "@/lib/slug";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import { import {
ProgramSiraGecmisi, ProgramSiraGecmisi,
ProgramTabanTrendi, ProgramTabanTrendi,
@@ -82,12 +83,22 @@ function SiralamaDugmesi({
export function BolumProgramListesi({ export function BolumProgramListesi({
programlar, programlar,
bazAd, bazAd,
tabanYol,
sayfa,
}: { }: {
/** Bölümün TÜM programları — sıralama liste geneline uygulanır. */
programlar: BolumProgrami[]; programlar: BolumProgrami[];
bazAd: string; bazAd: string;
tabanYol: string;
sayfa: number;
}) { }) {
const [aktifAlan, setAktifAlan] = useState<SiralamaAlani | null>(null); const [aktifAlan, setAktifAlan] = useState<SiralamaAlani | null>(null);
const [yon, setYon] = useState<SiralamaYonu>("artan"); 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(() => { const siraliProgramlar = useMemo(() => {
if (!aktifAlan) return programlar; if (!aktifAlan) return programlar;
@@ -107,7 +118,18 @@ export function BolumProgramListesi({
.map(({ program }) => program); .map(({ program }) => program);
}, [aktifAlan, programlar, yon]); }, [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) { function sirala(alan: SiralamaAlani) {
setIstemciSayfa(1);
if (aktifAlan === alan) { if (aktifAlan === alan) {
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan")); setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
return; return;
@@ -116,124 +138,140 @@ export function BolumProgramListesi({
setYon("artan"); setYon("artan");
} }
return ( function istemciSayfaDegistir(n: number) {
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white"> setIstemciSayfa(n);
<Table> listeRef.current?.scrollIntoView({ block: "start" });
<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";
return ( return (
<TableRow key={p.id}> <>
<TableCell className="max-w-0 w-full"> <div
<span className="flex min-w-0 items-center gap-2.5"> ref={listeRef}
{p.universiteSlug ? ( className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white"
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" /> >
) : null} <Table>
<span className="min-w-0 flex-1"> <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 ? ( {p.universiteSlug ? (
<Link <UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
href={`/universite/${p.universiteSlug}`} ) : null}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" <span className="min-w-0 flex-1">
> {p.universiteSlug ? (
{uniAd} <Link
</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"
<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"
}`}
> >
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)} {uniAd}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{uniAd}
</span> </span>
) : null} )}
<span className="truncate"> <span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
{p.il ? trBaslikDuzeni(p.il) : null} {p.unitur ? (
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""} <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> </span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span> </span>
</span> </span>
</span> </TableCell>
</TableCell> <TableCell className="hidden text-right text-xs md:table-cell">
<TableCell className="hidden text-right text-xs md:table-cell"> <ProgramSiraGecmisi program={p} />
<ProgramSiraGecmisi program={p} /> </TableCell>
</TableCell> <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell"> {p.puan2025 != null
{p.puan2025 != null ? p.puan2025.toFixed(2).replace(".", ",")
? p.puan2025.toFixed(2).replace(".", ",") : ""}
: "—"} </TableCell>
</TableCell> <TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"> {p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"} </TableCell>
</TableCell> <TableCell className="text-right text-xs tabular-nums text-slate-500">
<TableCell className="text-right text-xs tabular-nums text-slate-500"> <span className="inline-flex items-center gap-1">
<span className="inline-flex items-center gap-1"> <ProgramTabanTrendi program={p} />
<ProgramTabanTrendi program={p} /> {p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"} </span>
</span> </TableCell>
</TableCell> <TableCell className="pr-3 text-right">
<TableCell className="pr-3 text-right"> <ProgramEkleButonu
<ProgramEkleButonu program={p}
program={p} kaynak="bolum"
kaynak="bolum" className="size-8 min-h-8 min-w-8"
className="size-8 min-h-8 min-w-8" />
/> </TableCell>
</TableCell> </TableRow>
</TableRow> );
); })}
})} </TableBody>
</TableBody> </Table>
</Table> </div>
</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 Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react"; 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 * Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in
* kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek * kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek
* <Link>, Google her sayfayı ayrı statik URL olarak tarar. * <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({ export function Sayfalama({
tabanYol, tabanYol,
sayfa, sayfa,
toplamSayfa, toplamSayfa,
sorgu, sorgu,
onSayfa,
}: { }: {
tabanYol: string; tabanYol: string;
sayfa: number; sayfa: number;
toplamSayfa: number; toplamSayfa: number;
sorgu?: string; sorgu?: string;
onSayfa?: (n: number) => void;
}) { }) {
if (toplamSayfa <= 1) return null; if (toplamSayfa <= 1) return null;
const yol = (n: number) => { const yol = (n: number) => {
const path = n === 1 ? tabanYol : `${tabanYol}/sayfa/${n}`; const path = n === 1 ? tabanYol : `${tabanYol}/sayfa/${n}`;
return sorgu ? `${path}?${sorgu}` : path; 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 // 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son
const numaralar: (number | "...")[] = []; const numaralar: (number | "...")[] = [];
@@ -49,16 +83,17 @@ export function Sayfalama({
aria-label="Sayfalama" aria-label="Sayfalama"
className="mt-6 flex flex-wrap items-center justify-center gap-1.5" className="mt-6 flex flex-wrap items-center justify-center gap-1.5"
> >
{sayfa > 1 ? ( {sayfa > 1
<Link ? gecis(
href={yol(sayfa - 1)} sayfa - 1,
rel="prev" "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",
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 />
<ChevronLeft className="size-4" aria-hidden /> Önceki
Önceki </>,
</Link> { rel: "prev" },
) : null} )
: null}
{numaralar.map((n, i) => {numaralar.map((n, i) =>
n === "..." ? ( n === "..." ? (
<span <span
@@ -69,30 +104,27 @@ export function Sayfalama({
</span> </span>
) : ( ) : (
<Link gecis(
key={n} n,
href={yol(n)} n === sayfa
aria-current={n === sayfa ? "page" : undefined} ? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
className={ : "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 === sayfa n,
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground" { aktif: n === sayfa },
: "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>
), ),
)} )}
{sayfa < toplamSayfa ? ( {sayfa < toplamSayfa
<Link ? gecis(
href={yol(sayfa + 1)} sayfa + 1,
rel="next" "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",
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
Sonraki <ChevronRight className="size-4" aria-hidden />
<ChevronRight className="size-4" aria-hidden /> </>,
</Link> { rel: "next" },
) : null} )
: null}
</nav> </nav>
); );
} }

View File

@@ -61,9 +61,17 @@ async function geciciHatadaTekrarla<T>(
throw sonHata; 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 * Ş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: { export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
system: string; system: string;
@@ -71,12 +79,16 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
sema: T; sema: T;
maxTokens: number; maxTokens: number;
}): Promise<z.infer<T> | null> { }): 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") { if (saglayici() === "gemini") {
const yanit = await geciciHatadaTekrarla(() => const yanit = await geciciHatadaTekrarla(() =>
getGemini().models.generateContent({ getGemini().models.generateContent({
model: raporModeli(), model: raporModeli(),
contents: opts.kullanici, contents: opts.kullanici,
config: { config: {
abortSignal: sinyal,
systemInstruction: opts.system, systemInstruction: opts.system,
maxOutputTokens: opts.maxTokens, maxOutputTokens: opts.maxTokens,
responseMimeType: "application/json", responseMimeType: "application/json",
@@ -95,14 +107,17 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
} }
} }
const yanit = await getAnthropic().messages.parse({ const yanit = await getAnthropic().messages.parse(
model: raporModeli(), {
max_tokens: opts.maxTokens, model: raporModeli(),
thinking: { type: "adaptive" }, max_tokens: opts.maxTokens,
system: opts.system, thinking: { type: "adaptive" },
messages: [{ role: "user", content: opts.kullanici }], system: opts.system,
output_config: { format: zodOutputFormat(opts.sema) }, messages: [{ role: "user", content: opts.kullanici }],
}); output_config: { format: zodOutputFormat(opts.sema) },
},
{ signal: sinyal },
);
return yanit.parsed_output ?? null; return yanit.parsed_output ?? null;
} }