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

@@ -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({