Enhance error handling and user experience in AI-related features
Some checks failed
Deploy / deploy (push) Failing after 38m47s
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:
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Sayfalama
|
|
||||||
tabanYol={`/bolum/${slug}`}
|
tabanYol={`/bolum/${slug}`}
|
||||||
sayfa={sayfa}
|
sayfa={sayfa}
|
||||||
toplamSayfa={toplamSayfa}
|
|
||||||
/>
|
/>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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,6 +322,16 @@ export function SohbetClient({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{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
|
<PromptInput
|
||||||
value={girdi}
|
value={girdi}
|
||||||
onValueChange={setGirdi}
|
onValueChange={setGirdi}
|
||||||
@@ -342,6 +359,7 @@ export function SohbetClient({
|
|||||||
</PromptInputAction>
|
</PromptInputAction>
|
||||||
</PromptInputActions>
|
</PromptInputActions>
|
||||||
</PromptInput>
|
</PromptInput>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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
|
||||||
|
açı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
|
||||||
|
|||||||
@@ -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ı." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,8 +138,17 @@ export function BolumProgramListesi({
|
|||||||
setYon("artan");
|
setYon("artan");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function istemciSayfaDegistir(n: number) {
|
||||||
|
setIstemciSayfa(n);
|
||||||
|
listeRef.current?.scrollIntoView({ block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
<>
|
||||||
|
<div
|
||||||
|
ref={listeRef}
|
||||||
|
className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white"
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -155,7 +186,7 @@ export function BolumProgramListesi({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{siraliProgramlar.map((p) => {
|
{gosterilenProgramlar.map((p) => {
|
||||||
const uniAd = trBaslikDuzeni(p.universite);
|
const uniAd = trBaslikDuzeni(p.universite);
|
||||||
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
|
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
|
||||||
const devlet = p.unitur === "DEVLET";
|
const devlet = p.unitur === "DEVLET";
|
||||||
@@ -235,5 +266,12 @@ export function BolumProgramListesi({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<Sayfalama
|
||||||
|
tabanYol={tabanYol}
|
||||||
|
sayfa={gosterilenSayfa}
|
||||||
|
toplamSayfa={toplamSayfa}
|
||||||
|
onSayfa={aktifAlan ? istemciSayfaDegistir : undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
</>,
|
||||||
) : null}
|
{ rel: "prev" },
|
||||||
|
)
|
||||||
|
: 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)}
|
|
||||||
aria-current={n === sayfa ? "page" : undefined}
|
|
||||||
className={
|
|
||||||
n === sayfa
|
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 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"
|
: "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 },
|
||||||
{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>
|
</>,
|
||||||
) : null}
|
{ rel: "next" },
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
model: raporModeli(),
|
||||||
max_tokens: opts.maxTokens,
|
max_tokens: opts.maxTokens,
|
||||||
thinking: { type: "adaptive" },
|
thinking: { type: "adaptive" },
|
||||||
system: opts.system,
|
system: opts.system,
|
||||||
messages: [{ role: "user", content: opts.kullanici }],
|
messages: [{ role: "user", content: opts.kullanici }],
|
||||||
output_config: { format: zodOutputFormat(opts.sema) },
|
output_config: { format: zodOutputFormat(opts.sema) },
|
||||||
});
|
},
|
||||||
|
{ signal: sinyal },
|
||||||
|
);
|
||||||
return yanit.parsed_output ?? null;
|
return yanit.parsed_output ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user