refactor: rapor feature'ı — listem, yazdır, sohbet ve actions taşındı (Faz 8)
- app/sonuc/actions.ts → features/rapor/rapor-actions.ts birebir taşındı (kredi harcama/iade idempotency yolları bayt bayt aynı, yalnızca import yolları değişti); liste-uretici/revizyon-kutusu action'ı doğrudan import ediyor - lib/rapor-kaydi.ts rapor-queries.ts'e emildi: kullanicininRaporu aynı tek okuma kapısı; listem sayfasındaki inline drizzle sorgusu sohbetGecmisiGetir oldu; tadimlikSec re-export'u eklendi - listem-govde, sohbet-client, revizyon-kutusu, liste-uretici, listem-bos-cta, rapor-listesi, tadimlik-satiri, yazdir-butonu features/rapor/components/'a - Yeni async ListemIcerik (verifySession + maskeleme + bosDurum dalı içeride; çift await searchParams kalktı) ve RaporYazdir (3 redirect + yazdırılabilir belge); skeleton'lar eski loading.tsx geometrileriyle aynı dosyada - /listem ve /rapor/yazdir senkron: searchParams.then() + sayfada Suspense; maxDuration=300 ve metadata sayfada kaldı; iki loading.tsx silindi - Davranış değişikliği yok; /api/soru el değmedi Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
krediBittiIsaretle,
|
||||
} from "@/lib/credits";
|
||||
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||
import { sohbetAkisi } from "@/lib/ai/cagri";
|
||||
import { listeOzetiCikar } from "@/lib/ai/rapor";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// /listem üretim gövdesi: sihirbazın kaydettiği bekleyen seçimlerle Yapay
|
||||
// Zeka listesi burada kurulur (sonuç sayfası yalnızca buraya yönlendirir).
|
||||
// Başarıda profil tüketilir ve sayfa yenilenir; kredi-yetersiz ve hata
|
||||
// durumları da bu bileşende karşılanır.
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Lock, RefreshCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { listeOlustur } from "@/app/sonuc/actions";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { sonucHref, type TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku, tercihProfiliSil } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
|
||||
const URETIM_MESAJLARI = [
|
||||
"Sıralamana uygun programlar taranıyor…",
|
||||
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
||||
"Her tercih için risk ve trend analizi yazılıyor…",
|
||||
"Son rötuşlar yapılıyor, neredeyse hazır…",
|
||||
];
|
||||
|
||||
type Durum =
|
||||
| { ad: "uretiliyor" }
|
||||
| { ad: "kredi-yetersiz" }
|
||||
| { ad: "hata"; mesaj: string }
|
||||
| { ad: "profil-yok" };
|
||||
|
||||
export function ListeUretici({
|
||||
hasPaket,
|
||||
bosDurum,
|
||||
}: {
|
||||
hasPaket: boolean;
|
||||
/** Storage'da bekleyen seçim yoksa gösterilecek boş-liste durumu */
|
||||
bosDurum: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [durum, setDurum] = useState<Durum>({ ad: "uretiliyor" });
|
||||
const [profil, setProfil] = useState<TercihProfili | null>(null);
|
||||
// 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);
|
||||
const basladi = useRef(false);
|
||||
|
||||
async function uret(profil: TercihProfili) {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
setDurum({ ad: "uretiliyor" });
|
||||
|
||||
try {
|
||||
const sonuc = await listeOlustur({
|
||||
sira: profil.sira,
|
||||
tur: profil.tur,
|
||||
secimler: profil.secimler,
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
if (sonuc.ok) {
|
||||
requestIdRef.current = null;
|
||||
olay("liste_olusturuldu", {
|
||||
tur: profil.tur,
|
||||
sira_kovasi: siraKovasi(profil.sira),
|
||||
});
|
||||
tercihProfiliSil();
|
||||
// ?uret=1 URL'de kalmasın: yenileme/geri dönüş üretimi tekrar
|
||||
// tetiklemesin. refresh sunucudaki raporu (ve header'daki kredi
|
||||
// pill'ini) ekrana getirir.
|
||||
window.history.replaceState(null, "", "/listem");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
|
||||
olay("kredi_bitti");
|
||||
setDurum({ ad: "kredi-yetersiz" });
|
||||
} else if (sonuc.code === "AUTH") {
|
||||
router.push(`/giris?callback=${encodeURIComponent("/listem?uret=1")}`);
|
||||
} else {
|
||||
setDurum({ ad: "hata", mesaj: sonuc.error });
|
||||
}
|
||||
} catch {
|
||||
setDurum({
|
||||
ad: "hata",
|
||||
mesaj: "Beklenmeyen bir hata oluştu, tekrar dener misin?",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (basladi.current) return;
|
||||
basladi.current = true;
|
||||
// Bekleyen seçimler yalnızca client storage'da; mount sonrası okunur.
|
||||
const kayitli = tercihProfiliOku();
|
||||
if (!kayitli) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDurum({ ad: "profil-yok" });
|
||||
return;
|
||||
}
|
||||
setProfil(kayitli);
|
||||
void uret(kayitli);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (durum.ad === "profil-yok") return <>{bosDurum}</>;
|
||||
|
||||
if (durum.ad === "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="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 />
|
||||
<h1 className="mt-4 font-heading text-xl font-bold">
|
||||
Kredin bu liste için yetmiyor
|
||||
</h1>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{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 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
{hasPaket ? "+30 kredi yükle — 129 TL" : "Paketi aktive et — 299 TL"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (durum.ad === "hata") {
|
||||
return (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||
<h1 className="font-heading text-xl font-bold">Liste kurulamadı</h1>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
{durum.mesaj}
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => profil && void uret(profil)}
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
Tekrar dene
|
||||
</Button>
|
||||
{profil ? (
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
Seçimlerin kayıtlı —{" "}
|
||||
<Link href={sonucHref(profil)} className="underline">
|
||||
sonuç sayfasına dönebilirsin
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return <UretimGovdesi />;
|
||||
}
|
||||
|
||||
function UretimGovdesi() {
|
||||
const [mesajIdx, setMesajIdx] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-live="polite"
|
||||
className="rounded-2xl border border-slate-200 bg-white p-6 sm:p-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<h1 className="font-heading text-lg font-bold">Listen hazırlanıyor</h1>
|
||||
<p className="max-w-sm text-sm text-slate-500">
|
||||
{URETIM_MESAJLARI[mesajIdx]}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gelmekte olan tercih listesinin iskeleti */}
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-44" />
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { useTercihProfili } from "@/features/liste/hooks/use-tercih-profili";
|
||||
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
|
||||
/**
|
||||
* /listem boş hâlinin metni + CTA'sı: sıralamasını zaten girmiş adaya
|
||||
* "sıralamanı gir" demek yerine sihirbazı açık hâlde sonuç sayfasına
|
||||
* götürür; profil yoksa ana sayfadaki sıralama formuna yönlendirir.
|
||||
*/
|
||||
export function ListemBosCta() {
|
||||
const profil = useTercihProfili();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mt-3 text-slate-600">
|
||||
{profil
|
||||
? "Sıralaman kayıtlı — birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."
|
||||
: "Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur; sonra burada görüntüleyip Yapay Zeka danışmanla üzerinde konuşabilirsin."}
|
||||
</p>
|
||||
<PagePixelDivider seed={67} className="mx-auto mt-6" />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}>
|
||||
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// /listem gövdesi: Türkiye haritası + 24'lük liste + danışman sohbeti tek
|
||||
// ekranda. Harita ⇄ liste köprüsü (pinden satıra, satırdan haritaya) ve
|
||||
// paketsiz paywall kartları burada; veri çekimi sunucuda (page.tsx) kalır.
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Check,
|
||||
FileText,
|
||||
Lock,
|
||||
PencilLine,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RaporListesi } from "@/components/rapor-listesi";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { TercihHaritasi } from "@/features/liste/components/tercih-haritasi";
|
||||
import { pinleriTuret } from "@/lib/harita-pinler";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import { SohbetClient, type DanismanOzeti, type Mesaj } from "./sohbet-client";
|
||||
import { RevizyonKutusu } from "./revizyon-kutusu";
|
||||
|
||||
export function ListemGovde({
|
||||
rapor,
|
||||
adaySira,
|
||||
hasPaket,
|
||||
kilitAcilisi,
|
||||
kilitliBaslangic,
|
||||
kredi,
|
||||
sohbetGecmisi,
|
||||
ozet,
|
||||
kalanHak,
|
||||
paketFiyati,
|
||||
paketKredi,
|
||||
}: {
|
||||
rapor: RaporSonuc;
|
||||
adaySira: number;
|
||||
hasPaket: boolean;
|
||||
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
|
||||
kilitAcilisi: boolean;
|
||||
/** Paketsizde ACIK_SATIR; paketlide undefined (tamamı açık) */
|
||||
kilitliBaslangic?: number;
|
||||
kredi: number;
|
||||
sohbetGecmisi: Mesaj[];
|
||||
ozet?: DanismanOzeti;
|
||||
kalanHak: number;
|
||||
paketFiyati: string;
|
||||
paketKredi: number;
|
||||
}) {
|
||||
// Harita ⇄ liste köprüsü: pinden satıra, satırdan haritaya
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
const [acikSira, setAcikSira] = useState<number | null>(null);
|
||||
const haritaRef = useRef<HTMLElement>(null);
|
||||
|
||||
const pinVerisi = useMemo(
|
||||
() =>
|
||||
pinleriTuret(
|
||||
rapor,
|
||||
adaySira,
|
||||
kilitliBaslangic ?? rapor.tercihler.length,
|
||||
),
|
||||
[rapor, adaySira, kilitliBaslangic],
|
||||
);
|
||||
|
||||
const acikSatir = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
|
||||
// Kapı ölçümü: paywall yüzeyleri viewport'a girince (oturumda 1'er kez).
|
||||
// Ref'ler yalnızca paketsiz dallarda bağlanır; paketlide DOM'da olmadıkları
|
||||
// için observer hiç kurulmaz.
|
||||
const kilitSatirRef = useGorunumOlayi<HTMLDivElement>(
|
||||
"kilit_goruntulendi",
|
||||
{ yer: "satir" },
|
||||
"satir",
|
||||
);
|
||||
const kilitKartRef = useGorunumOlayi<HTMLElement>(
|
||||
"kilit_goruntulendi",
|
||||
{ yer: "kart" },
|
||||
"kart",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{/* Harita: listenin coğrafyası — ile dokun, üniversiteleri gör */}
|
||||
<section
|
||||
ref={haritaRef}
|
||||
aria-label="Tercih listesinin Türkiye haritası"
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<TercihHaritasi
|
||||
pinler={pinVerisi.pinler}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
onTercihSec={setAcikSira}
|
||||
/>
|
||||
</div>
|
||||
{pinVerisi.haritaDisi > 0 ? (
|
||||
<p className="mt-1 text-center text-xs text-slate-500">
|
||||
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Liste: 24 tercih ve taban verileri. Sayfa başlığı ve revizyon/PDF
|
||||
aksiyonları bu kutunun kendi başlık satırında yaşar. */}
|
||||
<section className={`min-w-0${kilitAcilisi ? " kt-unblur" : ""}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-1">
|
||||
<h1 className="min-w-0 font-heading text-xl font-bold tracking-tight sm:text-2xl">
|
||||
{hasPaket
|
||||
? "Yapay Zeka tercih listen"
|
||||
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
|
||||
</h1>
|
||||
{hasPaket ? (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="#revizyon">
|
||||
<PencilLine className="size-4" aria-hidden />
|
||||
Revize et
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Link href="/rapor/yazdir" target="_blank">
|
||||
<FileText className="size-4" aria-hidden />
|
||||
PDF
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<RaporListesi
|
||||
rapor={rapor}
|
||||
adaySira={adaySira}
|
||||
acikSira={acikSira}
|
||||
onHaritadaGor={(il) => {
|
||||
if (!il) return;
|
||||
setSeciliIl(il);
|
||||
haritaRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}}
|
||||
kilitliBaslangic={kilitliBaslangic}
|
||||
kilitOverlay={
|
||||
<div
|
||||
ref={kilitSatirRef}
|
||||
className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-heading font-bold text-slate-900">
|
||||
İlk {acikSatir} tercihte Yapay Zeka analizini gördün
|
||||
</p>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||
Kalan {rapor.tercihler.length - acikSatir} satırda senin
|
||||
sıralamana özel gerekçe ve risk notu hazır — paketle
|
||||
açılır. Program adları ve taban verileri her zaman
|
||||
ücretsiz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Analizleri aç — {paketFiyati} TL
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{hasPaket ? <RevizyonKutusu kalanHak={kalanHak} /> : null}
|
||||
</div>
|
||||
|
||||
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
|
||||
{hasPaket ? (
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Sohbet paket kartının üstünde: #sohbet çapası (ve mobildeki
|
||||
"Danışmana sor" butonu) doğrudan danışmana düşsün. */}
|
||||
<SohbetClient
|
||||
baslangicKredi={kredi}
|
||||
initialMesajlar={sohbetGecmisi}
|
||||
ozet={ozet}
|
||||
className="h-[420px]"
|
||||
/>
|
||||
|
||||
<section
|
||||
ref={kilitKartRef}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
||||
>
|
||||
<div className="border-b border-slate-100 p-6">
|
||||
<h3 className="font-heading text-xl font-bold tracking-tight">
|
||||
Tablo ücretsiz.
|
||||
<br />
|
||||
Karar desteği pakette.
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
Sadece veriyi göstermez; hangi tercihin neden mantıklı
|
||||
olduğunu sıralamana göre açıklar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ul className="flex flex-col gap-3 text-sm">
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli Yapay Zeka yorumu",
|
||||
"2 liste revizyonu ve paylaşılabilir PDF",
|
||||
`${paketKredi} Yapay Zeka danışman sorusu`,
|
||||
].map((ozellik) => (
|
||||
<li key={ozellik} className="flex items-start gap-2.5">
|
||||
<Check
|
||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{ozellik}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-xs text-slate-500">Tek seferlik</p>
|
||||
<p className="font-heading text-3xl font-bold">
|
||||
{paketFiyati} TL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="mt-5 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="size-3.5" aria-hidden />
|
||||
iyzico güvenli ödeme
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Bot className="size-3.5" aria-hidden />
|
||||
Gerçek YÖK verisi
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ListemLoading() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-10">
|
||||
<Skeleton className="h-6 w-40 rounded-full" />
|
||||
<Skeleton className="mt-4 h-9 w-3/4 max-w-xl" />
|
||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
||||
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-[480px] w-full rounded-2xl" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { MessageCircleQuestion, Sparkles } from "lucide-react";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import type { RaporParams } from "@/features/rapor/types/rapor";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import type { MaskeliRapor } from "@/features/rapor/types/rapor";
|
||||
import type { Mesaj } from "./sohbet-client";
|
||||
import { ListemGovde } from "./listem-govde";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
import { ListeUretici } from "./liste-uretici";
|
||||
import {
|
||||
ListemIcerik,
|
||||
ListemIcerikSkeleton,
|
||||
} from "@/features/rapor/components/listem-icerik";
|
||||
|
||||
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 60 sn abort
|
||||
// sınırı, bkz. lib/ai/cagri.ts) → en kötü ~120 sn. Platform limiti bundan
|
||||
@@ -29,126 +17,16 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export default async function ListemPage({
|
||||
export default function ListemPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ acildi?: string; uret?: string }>;
|
||||
}) {
|
||||
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
|
||||
// seçimlerle üretim kaldığı yerden başlasın.
|
||||
const ilkParams = await searchParams;
|
||||
const geriYol = ilkParams.uret === "1" ? "/listem?uret=1" : "/listem";
|
||||
const session = await verifySession(geriYol);
|
||||
const [user, params, satir, sohbetGecmisi] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
searchParams,
|
||||
kullanicininRaporu(session.user.id),
|
||||
appDb
|
||||
.select({
|
||||
id: schema.chatMessages.id,
|
||||
role: schema.chatMessages.role,
|
||||
content: schema.chatMessages.content,
|
||||
})
|
||||
.from(schema.chatMessages)
|
||||
.where(eq(schema.chatMessages.userId, session.user.id))
|
||||
.orderBy(asc(schema.chatMessages.createdAt))
|
||||
.limit(100),
|
||||
]);
|
||||
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
||||
|
||||
const { acildi } = params;
|
||||
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
|
||||
const raporVar = Boolean(satir?.result);
|
||||
|
||||
if (!raporVar) {
|
||||
const bosDurum = (
|
||||
<section className="mx-auto flex max-w-2xl flex-col items-center px-4 py-16 text-center">
|
||||
<Sparkles className="size-10 text-primary" aria-hidden />
|
||||
<h1 className="mt-4 font-heading text-2xl font-bold">
|
||||
Henüz Yapay Zeka listen yok
|
||||
</h1>
|
||||
<ListemBosCta />
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-4xl px-4 py-12">
|
||||
{/* ?uret=1: sihirbazdan yönlendirildi — bekleyen seçimlerle üretim
|
||||
burada koşar, animasyon ve hata/kredi durumları burada yaşar. */}
|
||||
{params.uret === "1" ? (
|
||||
<ListeUretici
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
bosDurum={bosDurum}
|
||||
/>
|
||||
) : (
|
||||
bosDurum
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rapor: MaskeliRapor = user.hasPaket
|
||||
? (satir!.result as RaporSonuc)
|
||||
: raporMaskele(satir!.result as RaporSonuc);
|
||||
const raporParams = satir!.params as RaporParams;
|
||||
// Danışman özeti sohbetin açılış mesajı olarak gösterilir; sohbet
|
||||
// /api/soru üzerinden zaten liste + seçim bağlamıyla çalışıyor.
|
||||
// Paketsizde rapor zaten maskeli: 1 uyarı açık, kalanının yalnızca sayısı var.
|
||||
const danismanOzeti = rapor.genelDegerlendirme
|
||||
? {
|
||||
metin: rapor.genelDegerlendirme,
|
||||
uyarilar: rapor.uyarilar,
|
||||
kilitli: !user.hasPaket,
|
||||
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-12">
|
||||
{kilitAcilisi ? (
|
||||
<div
|
||||
role="status"
|
||||
className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800"
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" aria-hidden />
|
||||
Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve
|
||||
PDF artık açık.
|
||||
</div>
|
||||
) : null}
|
||||
{/* Başlık ve revizyon/PDF aksiyonları listenin kendi başlık satırında
|
||||
(bkz. listem-govde.tsx) — sayfa üstünde ayrı bir header yok. */}
|
||||
<ListemGovde
|
||||
rapor={rapor}
|
||||
adaySira={raporParams.sira}
|
||||
hasPaket={Boolean(user.hasPaket)}
|
||||
kilitAcilisi={kilitAcilisi}
|
||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||
kredi={user.creditBalance}
|
||||
sohbetGecmisi={sohbetGecmisi as Mesaj[]}
|
||||
ozet={danismanOzeti}
|
||||
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
|
||||
paketFiyati={PAKET_FIYATI}
|
||||
paketKredi={URUNLER.paket.credits}
|
||||
/>
|
||||
|
||||
<ViewportPortal>
|
||||
<a
|
||||
href="#sohbet"
|
||||
className="fixed bottom-5 right-4 z-40 inline-flex h-11 items-center gap-2 rounded-full bg-primary px-4 text-sm font-semibold text-white shadow-lg transition-transform duration-200 active:scale-[0.97] lg:hidden print:hidden"
|
||||
>
|
||||
<MessageCircleQuestion className="size-4" aria-hidden />
|
||||
Danışmana sor
|
||||
</a>
|
||||
</ViewportPortal>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
<Suspense fallback={<ListemIcerikSkeleton />}>
|
||||
{searchParams.then(({ acildi, uret }) => (
|
||||
<ListemIcerik uret={uret === "1"} acildi={acildi === "1"} />
|
||||
))}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Paketlinin geri bildirime dayalı liste revizyonu (/listem üzerinde).
|
||||
// listeRevize sunucu aksiyonunu çağırır; başarıda sayfayı yeniler ki
|
||||
// sunucuda güncellenen rapor ve kredi bakiyesi ekrana yansısın.
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { listeRevize } from "@/app/sonuc/actions";
|
||||
import { olay } from "@/lib/analitik";
|
||||
|
||||
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 (
|
||||
<section
|
||||
id="revizyon"
|
||||
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-white p-5"
|
||||
>
|
||||
<p className="text-sm text-slate-500">
|
||||
Revizyon hakların doldu. Danışman sohbetinden listen hakkında soru
|
||||
sormaya devam edebilirsin.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
async function revizeEt() {
|
||||
setPending(true);
|
||||
try {
|
||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||
const sonuc = await listeRevize({
|
||||
feedback: feedback.trim(),
|
||||
requestId: requestIdRef.current,
|
||||
});
|
||||
requestIdRef.current = null;
|
||||
if (sonuc.ok) {
|
||||
olay("liste_revize", { sayfa: "listem" });
|
||||
toast.success("Listen yeniden kuruldu.");
|
||||
setFeedback("");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
id="revizyon"
|
||||
className="scroll-mt-24 rounded-2xl border border-slate-200 bg-white p-5"
|
||||
>
|
||||
<h2 className="font-heading text-base font-bold">
|
||||
Listede değişiklik mi istiyorsun?
|
||||
</h2>
|
||||
<p className="mb-3 mt-1 text-sm text-slate-600">
|
||||
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun
|
||||
(3 kredi).
|
||||
</p>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending || feedback.trim().length < 5}
|
||||
onClick={revizeEt}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCcw className="size-4" aria-hidden />
|
||||
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
Kalan hak: <span className="font-semibold">{kalanHak}</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { AlertTriangle, ArrowUp, Coins, Lock, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChatContainerContent,
|
||||
ChatContainerRoot,
|
||||
ChatContainerScrollAnchor,
|
||||
} from "@/components/ui/chat-container";
|
||||
import { Message, MessageContent } from "@/components/ui/message";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputAction,
|
||||
PromptInputActions,
|
||||
PromptInputTextarea,
|
||||
} from "@/components/ui/prompt-input";
|
||||
import { PromptSuggestion } from "@/components/ui/prompt-suggestion";
|
||||
import { ScrollButton } from "@/components/ui/scroll-button";
|
||||
import { DotsLoader } from "@/components/ui/loader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { olay } from "@/lib/analitik";
|
||||
|
||||
export interface Mesaj {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DanismanOzeti {
|
||||
metin: string;
|
||||
uyarilar?: string[];
|
||||
kilitli?: boolean;
|
||||
/** Paketsizde açık uyarının ardından kilitli kalan uyarı sayısı. */
|
||||
kilitliUyariSayisi?: number;
|
||||
}
|
||||
|
||||
/** Danışman mesajlarının avatarı — KolayTercih markası. */
|
||||
function DanismanAvatar() {
|
||||
return (
|
||||
<span
|
||||
className="mt-0.5 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-slate-200 bg-white"
|
||||
aria-hidden
|
||||
>
|
||||
<Image
|
||||
src="/kolay-tercih-mark.svg"
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-5"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ORNEK_SORULAR = [
|
||||
"Listemdeki kırmızı (riskli) tercihler ne kadar riskli?",
|
||||
"İlk 5 tercihimin taban sıralaması trendi nasıl?",
|
||||
"Güvenli dilimden bir tercihi daha üste alsam mantıklı mı?",
|
||||
"X bölümü ile Y bölümü arasında kalırsam neye bakmalıyım?",
|
||||
];
|
||||
|
||||
/**
|
||||
* Listem sayfasındaki danışman sohbeti. /api/soru ile konuşur:
|
||||
* GET geçmiş + kredi, POST 1 kredi düşerek cevabı stream eder.
|
||||
*/
|
||||
export function SohbetClient({
|
||||
baslangicKredi,
|
||||
initialMesajlar,
|
||||
className,
|
||||
ozet,
|
||||
}: {
|
||||
baslangicKredi: number;
|
||||
/** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
|
||||
initialMesajlar?: Mesaj[];
|
||||
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
|
||||
className?: string;
|
||||
/** Raporun genel değerlendirmesi; sohbetin açılış mesajı olarak gösterilir */
|
||||
ozet?: DanismanOzeti;
|
||||
}) {
|
||||
const [mesajlar, setMesajlar] = useState<Mesaj[]>(initialMesajlar ?? []);
|
||||
const [girdi, setGirdi] = useState("");
|
||||
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;
|
||||
// popup gibi geç mount olan yerlerde eski davranış sürer.
|
||||
if (initialMesajlar != null) return;
|
||||
fetch("/api/soru")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
||||
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setYuklendi(true));
|
||||
}, [initialMesajlar]);
|
||||
|
||||
async function gonder(metin: string) {
|
||||
const mesaj = metin.trim();
|
||||
if (!mesaj || bekliyor) return;
|
||||
if (kredi < 1) {
|
||||
toast.error("Kredin bitti — devam etmek için kredi yükle.");
|
||||
return;
|
||||
}
|
||||
setGirdi("");
|
||||
setBekliyor(true);
|
||||
const clientMessageId = crypto.randomUUID();
|
||||
const asistanId = crypto.randomUUID();
|
||||
setMesajlar((m) => [
|
||||
...m,
|
||||
{ id: clientMessageId, role: "user", content: mesaj },
|
||||
{ id: asistanId, role: "assistant", content: "" },
|
||||
]);
|
||||
|
||||
let birikmis = "";
|
||||
try {
|
||||
const res = await fetch("/api/soru", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: mesaj, clientMessageId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
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);
|
||||
} else {
|
||||
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setKredi((k) => k - 1);
|
||||
// Bu bileşen /sonuc'taki popup'tan da kullanılıyor (sohbet-popup.tsx)
|
||||
olay("danisman_sorusu", {
|
||||
sayfa: location.pathname.startsWith("/listem") ? "listem" : "sonuc",
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
birikmis += decoder.decode(value, { stream: true });
|
||||
const anlik = birikmis;
|
||||
setMesajlar((m) =>
|
||||
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 {
|
||||
// Sunucu hata anında krediyi iade edip yarım cevabı kaydetmiş olabilir;
|
||||
// tahmin etme: kayıtlı geçmişi ve bakiyeyi sunucudan çek (boş balon da
|
||||
// bu senkronla temizlenir). Senkron da düşerse boş balonu elle kaldır.
|
||||
try {
|
||||
const res = await fetch("/api/soru");
|
||||
const data = res.ok ? await res.json() : null;
|
||||
if (data?.mesajlar) setMesajlar(data.mesajlar);
|
||||
if (typeof data?.kredi === "number") setKredi(data.kredi);
|
||||
} catch {}
|
||||
if (!birikmis) {
|
||||
setMesajlar((m) => m.filter((x) => x.id !== asistanId));
|
||||
// Cevap hiç gelmedi; kullanıcının yazdığı soru kaybolmasın
|
||||
setGirdi(mesaj);
|
||||
}
|
||||
router.refresh();
|
||||
toast.error(
|
||||
birikmis
|
||||
? "Bağlantı koptu — cevabın buraya kadar üretilen kısmı kaydedildi."
|
||||
: "Bağlantı koptu, cevap alınamadı. Kredin harcandıysa otomatik iade edildi.",
|
||||
);
|
||||
} finally {
|
||||
setBekliyor(false);
|
||||
}
|
||||
}
|
||||
|
||||
const krediYok = kredi < 1;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Danışman sohbeti"
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
|
||||
className ?? "h-[calc(100dvh-16rem)] min-h-[400px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||
<h2 className="font-heading text-base font-bold">Danışmanla konuş</h2>
|
||||
<Link
|
||||
href="/paket"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100"
|
||||
title="Kredilerin — yüklemek için tıkla"
|
||||
>
|
||||
<Coins className="size-3.5" aria-hidden />
|
||||
{kredi} kredi
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<ChatContainerRoot className="h-full">
|
||||
<ChatContainerContent className="gap-4 p-4">
|
||||
{ozet ? (
|
||||
<Message className="justify-start">
|
||||
<DanismanAvatar />
|
||||
<div className="flex max-w-[85%] flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-slate-500">
|
||||
Danışman özeti
|
||||
</p>
|
||||
<MessageContent className="bg-slate-100 text-slate-800">
|
||||
{ozet.metin}
|
||||
</MessageContent>
|
||||
{ozet.uyarilar?.length ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ozet.uyarilar.map((uyari) => (
|
||||
<li
|
||||
key={uyari}
|
||||
className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
{uyari}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{ozet.kilitli ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
{ozet.kilitliUyariSayisi ? (
|
||||
<>
|
||||
{ozet.kilitliUyariSayisi} uyarı daha kilitli —
|
||||
sıralamana özel uyarıların pakette.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Değerlendirmenin tamamı ve sıralamana özel uyarılar
|
||||
pakette.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Message>
|
||||
) : null}
|
||||
{mesajlar.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4",
|
||||
!ozet && "items-center py-10 text-center",
|
||||
)}
|
||||
>
|
||||
{!ozet ? (
|
||||
<>
|
||||
<Sparkles
|
||||
className="mx-auto size-7 text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="font-heading font-bold">
|
||||
Listen hakkında her şeyi sorabilirsin
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-slate-600">
|
||||
Cevaplar senin 24'lük listen, seçimlerin ve gerçek
|
||||
YÖK Atlas verisi bağlamında üretilir. Her mesaj 1 kredi.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{yuklendi ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-lg flex-wrap gap-2",
|
||||
!ozet && "justify-center",
|
||||
)}
|
||||
>
|
||||
{ORNEK_SORULAR.map((s) => (
|
||||
<PromptSuggestion
|
||||
key={s}
|
||||
size="sm"
|
||||
className="cursor-pointer text-xs"
|
||||
disabled={krediYok}
|
||||
onClick={() => gonder(s)}
|
||||
>
|
||||
{s}
|
||||
</PromptSuggestion>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-lg flex-wrap gap-2",
|
||||
!ozet && "justify-center",
|
||||
)}
|
||||
>
|
||||
{[112, 148, 96, 132].map((w, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-7 rounded-full"
|
||||
style={{ width: w }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
mesajlar.map((m) =>
|
||||
m.role === "user" ? (
|
||||
<Message key={m.id} className="justify-end">
|
||||
<MessageContent className="max-w-[85%] bg-primary text-white">
|
||||
{m.content}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
) : (
|
||||
<Message key={m.id} className="justify-start">
|
||||
<DanismanAvatar />
|
||||
{m.content ? (
|
||||
<MessageContent
|
||||
markdown
|
||||
className="max-w-[85%] bg-slate-100 text-slate-800 prose-sm"
|
||||
>
|
||||
{m.content}
|
||||
</MessageContent>
|
||||
) : (
|
||||
<div className="w-full max-w-[85%] space-y-2 rounded-lg bg-slate-100 px-4 py-3">
|
||||
<Skeleton className="h-3.5 w-full bg-slate-200" />
|
||||
<Skeleton className="h-3.5 w-4/5 bg-slate-200" />
|
||||
<Skeleton className="h-3.5 w-3/5 bg-slate-200" />
|
||||
</div>
|
||||
)}
|
||||
</Message>
|
||||
),
|
||||
)
|
||||
)}
|
||||
<ChatContainerScrollAnchor />
|
||||
</ChatContainerContent>
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<ScrollButton className="cursor-pointer bg-white shadow-md" />
|
||||
</div>
|
||||
</ChatContainerRoot>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
{krediYok ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||
<p className="text-sm font-medium text-amber-800">
|
||||
Kredin bitti — sohbete devam etmek için kredi yükle.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
|
||||
>
|
||||
<Link href="/paket">Kredi yükle</Link>
|
||||
</Button>
|
||||
</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
|
||||
value={girdi}
|
||||
onValueChange={setGirdi}
|
||||
onSubmit={() => gonder(girdi)}
|
||||
isLoading={bekliyor}
|
||||
disabled={bekliyor}
|
||||
maxHeight={160}
|
||||
className="rounded-2xl border-slate-200"
|
||||
>
|
||||
<PromptInputTextarea
|
||||
placeholder="Sorunu yaz… (1 kredi)"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<PromptInputActions className="justify-end pt-1">
|
||||
<PromptInputAction tooltip="Gönder (Enter)">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => gonder(girdi)}
|
||||
disabled={bekliyor || !girdi.trim()}
|
||||
className="size-9 cursor-pointer rounded-full bg-orange-500 text-white hover:bg-orange-600"
|
||||
aria-label="Gönder"
|
||||
>
|
||||
{bekliyor ? <DotsLoader size="sm" /> : <ArrowUp className="size-4" aria-hidden />}
|
||||
</Button>
|
||||
</PromptInputAction>
|
||||
</PromptInputActions>
|
||||
</PromptInput>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { eq } from "drizzle-orm";
|
||||
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { verifySession } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||
import { odemeyiSonuclandir } from "@/lib/odeme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// /rapor/yazdir rapor sorgusunu bekler — belge düzeninde iskelet.
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main
|
||||
aria-live="polite"
|
||||
className="mx-auto w-full max-w-3xl bg-white p-8"
|
||||
>
|
||||
<div className="border-b-2 border-slate-200 pb-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-2 h-4 w-40" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { PUAN_TURLERI } from "@/types/yokatlas";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import type { RaporParams } from "@/features/rapor/types/rapor";
|
||||
import { YazdirButonu } from "./yazdir-butonu";
|
||||
import {
|
||||
RaporYazdir,
|
||||
RaporYazdirSkeleton,
|
||||
} from "@/features/rapor/components/rapor-yazdir";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Raporu (Yazdır)",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const DILIM_LABEL: Record<string, string> = {
|
||||
hayal: "Hayal (Riskli)",
|
||||
dengeli: "Dengeli (Az riskli)",
|
||||
garanti: "Garanti (Güvenli)",
|
||||
};
|
||||
|
||||
export default async function YazdirPage() {
|
||||
const session = await verifySession("/rapor/yazdir");
|
||||
// Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz
|
||||
const [user, satir] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
kullanicininRaporu(session.user.id),
|
||||
]);
|
||||
if (!user) redirect("/giris");
|
||||
if (!user.hasPaket) redirect("/paket");
|
||||
if (!satir?.result) redirect("/sonuc");
|
||||
|
||||
const rapor = satir.result as RaporSonuc;
|
||||
const params = satir.params as RaporParams;
|
||||
const tarih = new Date(satir.updatedAt).toLocaleDateString("tr-TR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export default function YazdirPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-white p-8 text-slate-900 print:p-[10mm]">
|
||||
<YazdirButonu />
|
||||
|
||||
<header className="border-b-2 border-slate-900 pb-4">
|
||||
<h1 className="font-heading text-2xl font-bold">
|
||||
{rapor.tercihler.length} Tercihlik Kişisel Liste ve Risk Raporu
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
|
||||
türü: {PUAN_TURLERI[params.tur]}
|
||||
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
|
||||
</p>
|
||||
{rapor.kapsam?.uyarlandi ? (
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Not: Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu;
|
||||
liste {rapor.kapsam.dagilim.hayal} hayal ·{" "}
|
||||
{rapor.kapsam.dagilim.dengeli} dengeli ·{" "}
|
||||
{rapor.kapsam.dagilim.garanti} garanti olarak kuruldu.
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="mt-5">
|
||||
<h2 className="font-heading text-base font-bold">
|
||||
Genel değerlendirme
|
||||
</h2>
|
||||
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed">
|
||||
{rapor.genelDegerlendirme}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<table className="mt-5 w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-slate-900 text-left">
|
||||
<th className="py-2 pr-2">#</th>
|
||||
<th className="py-2 pr-2">Program / Üniversite</th>
|
||||
<th className="py-2 pr-2">Dilim</th>
|
||||
<th className="py-2 pr-2">Gerekçe</th>
|
||||
<th className="py-2 pr-2">Risk notu</th>
|
||||
<th className="py-2">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rapor.tercihler.map((t) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
return (
|
||||
<tr
|
||||
key={t.sira}
|
||||
className="break-inside-avoid border-b border-slate-200 align-top"
|
||||
>
|
||||
<td className="py-2 pr-2 font-bold">{t.sira}</td>
|
||||
<td className="py-2 pr-2">
|
||||
<span className="font-semibold">{p?.isim}</span>
|
||||
<br />
|
||||
<span className="text-slate-600">
|
||||
{p?.universite} · {p?.il ?? "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-2">{DILIM_LABEL[t.dilim]}</td>
|
||||
<td className="py-2 pr-2">{t.gerekce}</td>
|
||||
<td className="py-2 pr-2">{t.riskNotu}</td>
|
||||
<td className="py-2">{t.trendOzeti}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<footer className="mt-6 border-t border-slate-300 pt-3 text-[10px] leading-relaxed text-slate-500">
|
||||
<p>
|
||||
Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi (2021–2025
|
||||
taban sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak
|
||||
üretilmiştir. Taban sıralamaları her yıl değişir; bu rapor yerleşme
|
||||
garantisi vermez. Tercih listesinin ÖSYM sistemine girilmesi ve nihai
|
||||
sorumluluğu adaya aittir.
|
||||
</p>
|
||||
<div className="mt-4 flex break-inside-avoid items-center justify-between">
|
||||
<div className="flex items-center font-bricolage text-slate-900">
|
||||
<Image
|
||||
src="/kolay-tercih-mark.svg"
|
||||
alt=""
|
||||
width={44}
|
||||
height={44}
|
||||
aria-hidden="true"
|
||||
className="size-11 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-light text-3xl tracking-tighter">Kolay</span>
|
||||
<span className="font-semibold text-3xl tracking-tighter">
|
||||
Tercih
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bricolage text-sm font-medium text-slate-900">
|
||||
kolaytercih.com
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<Suspense fallback={<RaporYazdirSkeleton />}>
|
||||
<RaporYazdir />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Printer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function YazdirButonu() {
|
||||
return (
|
||||
<div className="mb-6 flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 print:hidden">
|
||||
<p className="text-sm text-slate-600">
|
||||
"PDF olarak kaydet" seçeneğiyle raporu indirebilir veya
|
||||
yazdırabilirsin.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => window.print()}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
<Printer className="size-4" aria-hidden />
|
||||
Yazdır / PDF kaydet
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { getSession, getCurrentUser } from "@/lib/session";
|
||||
import { appDb, schema } from "@/lib/appdb";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
||||
import {
|
||||
spendCredits,
|
||||
grantCredits,
|
||||
krediBittiIsaretle,
|
||||
RAPOR_KREDI,
|
||||
MAX_REVIZYON,
|
||||
} from "@/lib/credits";
|
||||
import {
|
||||
raporUret,
|
||||
listeOzetiCikar,
|
||||
RaporUretimHatasi,
|
||||
} from "@/lib/ai/rapor";
|
||||
import type {
|
||||
RaporParams,
|
||||
RaporSonuc,
|
||||
} from "@/features/rapor/types/rapor";
|
||||
import { sihirbazDogrula, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { raporMaskele } from "@/lib/rapor-maske";
|
||||
|
||||
|
||||
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
|
||||
export type ListeSonuc =
|
||||
| {
|
||||
ok: true;
|
||||
rapor: RaporSonuc;
|
||||
params: RaporParams;
|
||||
revisionCount: number;
|
||||
kredi: number;
|
||||
hasPaket: boolean;
|
||||
}
|
||||
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
|
||||
|
||||
function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
|
||||
return (
|
||||
Number.isFinite(sira) && sira >= 1 && sira <= 4_000_000 && tur in PUAN_TURLERI
|
||||
);
|
||||
}
|
||||
|
||||
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) ve openai/anthropic
|
||||
// bu sınıflarda .name'i "Error" bırakıyor — ada ek olarak sınıf adına ve
|
||||
// mesaja da bak ("Request was aborted." / "Request timed out.").
|
||||
if (
|
||||
err instanceof Error &&
|
||||
/abort|timeout|timed out/i.test(
|
||||
`${err.name} ${err.constructor.name} ${err.message}`,
|
||||
)
|
||||
) {
|
||||
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ı");
|
||||
return "Liste üretimi şu anda başlatılamıyor. Birazdan tekrar dener misin? Harcanan kredin otomatik iade edilir.";
|
||||
}
|
||||
console.error("[liste] üretim hatası:", err);
|
||||
return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?";
|
||||
}
|
||||
|
||||
function paramsBirlestir(
|
||||
sira: number,
|
||||
tur: PuanTuruKey,
|
||||
secimler: SihirbazSecimleri,
|
||||
): RaporParams {
|
||||
return {
|
||||
sira: Math.round(sira),
|
||||
tur,
|
||||
kategoriler: secimler.kategoriler,
|
||||
iller: secimler.iller,
|
||||
universiteTipi: secimler.universiteTipi,
|
||||
oncelikler: secimler.oncelikler,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listeOlustur(input: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
secimler: unknown;
|
||||
requestId: string;
|
||||
}): Promise<ListeSonuc> {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||
}
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||
}
|
||||
// Paket şartı yok: üretim kredi bazlı (yeni kullanıcının 5 deneme kredisi
|
||||
// burada yanmaya başlar). Paketsize dönen rapor maskelenir.
|
||||
|
||||
if (!siraTurGecerli(input.sira, input.tur)) {
|
||||
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
|
||||
}
|
||||
const secimler = sihirbazDogrula(input.secimler);
|
||||
if (!secimler) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error: "En az bir ilgi alanı seçmelisin.",
|
||||
};
|
||||
}
|
||||
if (!input.requestId || typeof input.requestId !== "string") {
|
||||
return { ok: false, code: "HATA", error: "Geçersiz istek." };
|
||||
}
|
||||
|
||||
const params = paramsBirlestir(input.sira, input.tur, secimler);
|
||||
|
||||
// 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz)
|
||||
const harcama = await spendCredits({
|
||||
userId: user.id,
|
||||
amount: RAPOR_KREDI,
|
||||
reason: "report_generate",
|
||||
refId: input.requestId,
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
error: `Liste oluşturmak için ${RAPOR_KREDI} kredi gerekli.`,
|
||||
};
|
||||
}
|
||||
// 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 kullanicininRaporu(user.id);
|
||||
if (mevcut?.result) {
|
||||
const rapor = mevcut.result as RaporSonuc;
|
||||
return {
|
||||
ok: true,
|
||||
rapor: user.hasPaket ? rapor : raporMaskele(rapor),
|
||||
params: mevcut.params as RaporParams,
|
||||
revisionCount: mevcut.revisionCount,
|
||||
kredi: user.creditBalance,
|
||||
hasPaket: user.hasPaket,
|
||||
};
|
||||
}
|
||||
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
|
||||
}
|
||||
|
||||
let sonuc: RaporSonuc;
|
||||
try {
|
||||
sonuc = await raporUret(params);
|
||||
} catch (err) {
|
||||
// Üretim başarısız → krediyi iade et
|
||||
await grantCredits({
|
||||
userId: user.id,
|
||||
delta: RAPOR_KREDI,
|
||||
reason: "refund",
|
||||
refId: input.requestId,
|
||||
}).catch(() => {});
|
||||
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await appDb
|
||||
.insert(schema.reports)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: user.id,
|
||||
params,
|
||||
result: sonuc,
|
||||
revisionCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.reports.userId,
|
||||
set: { params, result: sonuc, revisionCount: 0, updatedAt: now },
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rapor: user.hasPaket ? sonuc : raporMaskele(sonuc),
|
||||
params,
|
||||
revisionCount: 0,
|
||||
kredi: user.creditBalance - RAPOR_KREDI,
|
||||
hasPaket: user.hasPaket,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listeRevize(input: {
|
||||
feedback: string;
|
||||
requestId: string;
|
||||
}): Promise<ListeSonuc> {
|
||||
const session = await getSession();
|
||||
if (!session) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
|
||||
if (!user.hasPaket) return { ok: false, code: "PAKET", error: "Paket gerekli." };
|
||||
|
||||
const feedback = (input.feedback ?? "").trim();
|
||||
if (feedback.length < 5) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error: "Ne değişsin istediğini kısaca yazmalısın.",
|
||||
};
|
||||
}
|
||||
if (!input.requestId) {
|
||||
return { ok: false, code: "HATA", error: "Geçersiz istek." };
|
||||
}
|
||||
|
||||
const rapor = await kullanicininRaporu(user.id);
|
||||
if (!rapor?.result) {
|
||||
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
|
||||
}
|
||||
if (rapor.revisionCount >= MAX_REVIZYON) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "HATA",
|
||||
error: `Revizyon hakkın doldu (en fazla ${MAX_REVIZYON}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const params = rapor.params as RaporParams;
|
||||
const onceki = rapor.result as RaporSonuc;
|
||||
|
||||
const harcama = await spendCredits({
|
||||
userId: user.id,
|
||||
amount: RAPOR_KREDI,
|
||||
reason: "report_revision",
|
||||
refId: input.requestId,
|
||||
});
|
||||
if (!harcama.ok) {
|
||||
if (harcama.error === "INSUFFICIENT") {
|
||||
await krediBittiIsaretle(user.id);
|
||||
return {
|
||||
ok: false,
|
||||
code: "KREDI",
|
||||
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 kullanicininRaporu(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ı." };
|
||||
}
|
||||
|
||||
let sonuc: RaporSonuc;
|
||||
try {
|
||||
sonuc = await raporUret(
|
||||
params,
|
||||
feedback.slice(0, 1000),
|
||||
listeOzetiCikar(onceki, params.sira),
|
||||
);
|
||||
} catch (err) {
|
||||
await grantCredits({
|
||||
userId: user.id,
|
||||
delta: RAPOR_KREDI,
|
||||
reason: "refund",
|
||||
refId: input.requestId,
|
||||
}).catch(() => {});
|
||||
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
|
||||
}
|
||||
|
||||
// Sayaç yalnızca başarılı üretimde artar
|
||||
await appDb
|
||||
.update(schema.reports)
|
||||
.set({
|
||||
result: sonuc,
|
||||
revisionCount: sql`${schema.reports.revisionCount} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.reports.id, rapor.id));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rapor: sonuc,
|
||||
params,
|
||||
revisionCount: rapor.revisionCount + 1,
|
||||
kredi: user.creditBalance - RAPOR_KREDI,
|
||||
hasPaket: true,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import type { Metadata } from "next";
|
||||
import { rankWindowFacets, searchByRank } from "@/lib/db";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { kullanicininRaporu } from "@/lib/rapor-kaydi";
|
||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
|
||||
import { SihirbazBolumu } from "@/features/sihirbaz/components/sihirbaz-cagri-karti";
|
||||
import { TadimlikSatiri } from "./tadimlik-satiri";
|
||||
import { TadimlikSatiri } from "@/features/rapor/components/tadimlik-satiri";
|
||||
import { ProgramTablosu } from "@/features/liste/components/program-tablosu";
|
||||
import { ManuelHarita } from "@/features/liste/components/manuel-harita";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Girişsiz yapay zekâ tadımlığı (PRD İP-B): /sonuc'ta, sıra kovası + (varsa)
|
||||
// sihirbaz alan tercihine uyan TEK örnek tercih satırı — gerçek rapor
|
||||
// satırının detay bloğuyla (RaporSatirDetayi) birebir aynı formatta.
|
||||
// İçerik batch üretilmiş havuzdan gelir; bu bileşen LLM'e istek attırmaz.
|
||||
// SSR "genel" satırı render eder (prop), mount sonrası sihirbaz kategorisi
|
||||
// varsa /api/tadimlik'ten kategoriye özel satır çekilip yerinde değiştirilir
|
||||
// (seçim sunucuda kalır). Havuzda içerik yoksa hiç render edilmez.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||
import { RaporSatirDetayi } from "@/components/rapor-listesi";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
raporSiraSerisi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { dilimdenRisk, riskHesapla } from "@/lib/risk";
|
||||
import { turkishSlugify } from "@/lib/slug";
|
||||
import { PROFIL_DEGISTI_EVENT, sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import type { TadimlikSatir } from "@/lib/tadimlik-havuzu";
|
||||
|
||||
const GENEL = "genel";
|
||||
|
||||
export function TadimlikSatiri({
|
||||
sira,
|
||||
tur,
|
||||
ilk,
|
||||
}: {
|
||||
sira: number;
|
||||
tur: string;
|
||||
/** SSR'da seçilmiş "genel" satır; havuz boşsa null. */
|
||||
ilk: TadimlikSatir | null;
|
||||
}) {
|
||||
const [satir, setSatir] = useState<TadimlikSatir | null>(ilk);
|
||||
const yuklenenSlug = useRef(GENEL);
|
||||
|
||||
// Sihirbaz kategorisi yalnızca localStorage'da yaşar; mount'ta ve profil
|
||||
// değiştikçe okunur, kategoriye özel satır sunucudan çekilir.
|
||||
useEffect(() => {
|
||||
const guncelle = () => {
|
||||
const profil = tercihProfiliOku();
|
||||
const kategori = profil?.secimler.kategoriler[0];
|
||||
const slug = kategori ? turkishSlugify(kategori) : GENEL;
|
||||
if (slug === yuklenenSlug.current) return;
|
||||
yuklenenSlug.current = slug;
|
||||
if (slug === GENEL) {
|
||||
setSatir(ilk);
|
||||
return;
|
||||
}
|
||||
fetch(
|
||||
`/api/tadimlik?sira=${sira}&tur=${encodeURIComponent(tur)}&kategori=${encodeURIComponent(slug)}`,
|
||||
)
|
||||
.then((yanit) => (yanit.ok ? yanit.json() : null))
|
||||
.then((veri: { tadimlik: TadimlikSatir | null } | null) => {
|
||||
// Yarış koruması: bu arada başka kategori yüklendiyse dokunma
|
||||
if (yuklenenSlug.current !== slug) return;
|
||||
if (veri?.tadimlik) setSatir(veri.tadimlik);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
guncelle();
|
||||
window.addEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
return () => window.removeEventListener(PROFIL_DEGISTI_EVENT, guncelle);
|
||||
}, [sira, tur, ilk]);
|
||||
|
||||
const gorunumRef = useGorunumOlayi<HTMLElement>("tadimlik_goruntulendi", {
|
||||
kova: siraKovasi(sira),
|
||||
});
|
||||
|
||||
if (!satir) return null;
|
||||
|
||||
const p = satir.program;
|
||||
const risk = riskHesapla(p.efektifSira, sira) ?? dilimdenRisk(satir.dilim);
|
||||
const seri = raporSiraSerisi(p.siraGecmisi);
|
||||
const taban = p.efektifSira ?? programEtkinSira(seri);
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`;
|
||||
|
||||
return (
|
||||
<section ref={gorunumRef} className="mt-16 min-w-0">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<SectionEyebrow>Yapay zekâdan örnek satır</SectionEyebrow>
|
||||
<h2 className="mt-4 font-heading text-2xl font-bold sm:text-3xl">
|
||||
Yapay zekâ listendeki her satırı böyle anlatır
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||
Sıralamana uygun bir programın gerekçesi, yerleşme riski ve taban
|
||||
trendi — 24 tercihlik listenin her satırı bu formatta kurulur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-6">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<UniLogo ad={p.universite} boy="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{p.isim}
|
||||
</span>
|
||||
<span className="mt-0.5 flex 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" : "Vakıf"}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{p.universite}
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right text-xs md:inline-flex">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs tabular-nums text-slate-500">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg bg-slate-50/60 px-4 py-4">
|
||||
<RaporSatirDetayi
|
||||
dilim={satir.dilim}
|
||||
risk={risk}
|
||||
unitur={p.unitur}
|
||||
gerekce={satir.gerekce}
|
||||
riskNotu={satir.riskNotu}
|
||||
trendOzeti={satir.trendOzeti}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col items-start gap-3 border-t border-slate-100 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-slate-600">
|
||||
Bu, 24 satırlık Yapay Zeka listenden yalnızca bir örnek.
|
||||
<span className="block text-xs text-slate-500">
|
||||
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
className="h-11 w-full shrink-0 cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600 sm:w-auto"
|
||||
>
|
||||
<Link
|
||||
href={girisUrl}
|
||||
onClick={() => olay("giris_cta_tiklandi", { kaynak: "tadimlik" })}
|
||||
>
|
||||
24 satırın tamamı için giriş yap — 5 deneme kredin hazır
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user