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:
204
src/features/rapor/components/liste-uretici.tsx
Normal file
204
src/features/rapor/components/liste-uretici.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"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 "@/features/rapor/rapor-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>
|
||||
);
|
||||
}
|
||||
38
src/features/rapor/components/listem-bos-cta.tsx
Normal file
38
src/features/rapor/components/listem-bos-cta.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
283
src/features/rapor/components/listem-govde.tsx
Normal file
283
src/features/rapor/components/listem-govde.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"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 "./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>
|
||||
);
|
||||
}
|
||||
150
src/features/rapor/components/listem-icerik.tsx
Normal file
150
src/features/rapor/components/listem-icerik.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { MessageCircleQuestion, Sparkles } from "lucide-react";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { kullanicininRaporu, sohbetGecmisiGetir } from "../rapor-queries";
|
||||
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
|
||||
import type {
|
||||
MaskeliRapor,
|
||||
RaporParams,
|
||||
RaporSonuc,
|
||||
} from "../types/rapor";
|
||||
import { ViewportPortal } from "@/components/viewport-portal";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
|
||||
import type { Mesaj } from "./sohbet-client";
|
||||
import { ListemGovde } from "./listem-govde";
|
||||
import { ListemBosCta } from "./listem-bos-cta";
|
||||
import { ListeUretici } from "./liste-uretici";
|
||||
|
||||
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
export async function ListemIcerik({
|
||||
uret,
|
||||
acildi,
|
||||
}: {
|
||||
uret: boolean;
|
||||
acildi: boolean;
|
||||
}) {
|
||||
// Girişsiz gelene callback'i ?uret=1 ile ver: girişten dönüşte bekleyen
|
||||
// seçimlerle üretim kaldığı yerden başlasın.
|
||||
const geriYol = uret ? "/listem?uret=1" : "/listem";
|
||||
const session = await verifySession(geriYol);
|
||||
const [user, satir, sohbetGecmisi] = await Promise.all([
|
||||
getCurrentUser(),
|
||||
kullanicininRaporu(session.user.id),
|
||||
sohbetGecmisiGetir(session.user.id),
|
||||
]);
|
||||
if (!user) redirect(`/giris?callback=${encodeURIComponent(geriYol)}`);
|
||||
|
||||
const kilitAcilisi = Boolean(user.hasPaket && acildi);
|
||||
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. */}
|
||||
{uret ? (
|
||||
<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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListemIcerikSkeleton() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
392
src/features/rapor/components/rapor-listesi.tsx
Normal file
392
src/features/rapor/components/rapor-listesi.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
// 24'lük listenin gövdesi. Görsel dil sonuç sayfasındaki ham program
|
||||
// tablosuyla birebir aynıdır (bkz. program-tablosu.tsx): aynı beyaz kart,
|
||||
// aynı satır düzeni, aynı "Son 5 yıl" / "Taban sıra" sütunları. Buradaki fark
|
||||
// içerik: satır sırası tercih numarası, risk sütunu Yapay Zeka'dan gelir ve
|
||||
// satır açıldığında gerekçe/risk notu görünür. Program adı ve tabanlar herkese
|
||||
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
|
||||
// sunucuda zaten maskelidir).
|
||||
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import {
|
||||
ProgramSiraGecmisi,
|
||||
ProgramTabanTrendi,
|
||||
programEtkinSira,
|
||||
raporSiraSerisi,
|
||||
} from "@/components/program-liste-verileri";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { bolumSayfaSlug, uniSayfaSlug } from "@/lib/slug";
|
||||
import type { RaporSonuc } from "@/features/rapor/types/rapor";
|
||||
import {
|
||||
RISK_ETIKET,
|
||||
dilimdenRisk,
|
||||
riskHesapla,
|
||||
type RiskSeviyesi,
|
||||
} from "@/lib/risk";
|
||||
|
||||
export const DILIM_ETIKET: Record<string, string> = {
|
||||
hayal: "Hayal",
|
||||
dengeli: "Dengeli",
|
||||
garanti: "Garanti",
|
||||
};
|
||||
|
||||
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
|
||||
// Nokta hiçbir yerde tek başına durmaz; yanındaki etiket dar ekranda sr-only
|
||||
// olarak kalır (renk tek başına anlam taşımaz).
|
||||
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; icon: typeof Rocket }> = {
|
||||
riskli: { nokta: "bg-red-500", icon: Rocket },
|
||||
"az-riskli": { nokta: "bg-amber-500", icon: Scale },
|
||||
guvenli: { nokta: "bg-emerald-500", icon: ShieldCheck },
|
||||
};
|
||||
|
||||
// Sütun sayısı: # · Program · Son 5 yıl · Risk · Taban sıra · detay oku
|
||||
const SUTUN_SAYISI = 6;
|
||||
|
||||
/**
|
||||
* Açık satırın detay bloğu: dilim/risk başlığı + gerekçe ve risk/trend
|
||||
* kartları. /sonuc'taki girişsiz tadımlık satırı da (tadimlik-satiri.tsx)
|
||||
* aynı bileşeni kullanır — "demo değil ürünün kendisi" hissi kopyayla değil
|
||||
* tek kaynakla sağlanır.
|
||||
*/
|
||||
export function RaporSatirDetayi({
|
||||
dilim,
|
||||
risk,
|
||||
unitur,
|
||||
gerekce,
|
||||
riskNotu,
|
||||
trendOzeti,
|
||||
}: {
|
||||
dilim: string;
|
||||
risk: RiskSeviyesi;
|
||||
unitur?: string | null;
|
||||
gerekce: string;
|
||||
riskNotu: string;
|
||||
trendOzeti: string;
|
||||
}) {
|
||||
const stil = RISK_STIL[risk];
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<stil.icon className="size-3.5" aria-hidden />
|
||||
<span className="font-medium">
|
||||
{DILIM_ETIKET[dilim]} · {RISK_ETIKET[risk]}
|
||||
</span>
|
||||
{unitur ? <span>· {unitur}</span> : null}
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Neden listede?
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{gerekce}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Risk ve Yapay Zeka yorumu
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">
|
||||
{riskNotu} {trendOzeti}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RaporListesi({
|
||||
rapor,
|
||||
adaySira,
|
||||
kilitliBaslangic,
|
||||
kilitOverlay,
|
||||
acikSira,
|
||||
onHaritadaGor,
|
||||
}: {
|
||||
rapor: RaporSonuc;
|
||||
adaySira?: number;
|
||||
kilitliBaslangic?: number;
|
||||
kilitOverlay?: React.ReactNode;
|
||||
/** Dışarıdan (haritadan) açtırılan satır — değişince satır açılıp kaydırılır */
|
||||
acikSira?: number | null;
|
||||
/** Detaydaki "Haritada gör" — il DB biçiminde */
|
||||
onHaritadaGor?: (il: string | null, sira: number) => void;
|
||||
}) {
|
||||
const [acik, setAcik] = useState<Set<number>>(new Set());
|
||||
const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
|
||||
|
||||
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
|
||||
useEffect(() => {
|
||||
if (acikSira == null) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAcik((mevcut) => new Set(mevcut).add(acikSira));
|
||||
satirRefs.current
|
||||
.get(acikSira)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [acikSira]);
|
||||
|
||||
function tiklandi(sira: number) {
|
||||
setAcik((mevcut) => {
|
||||
const yeni = new Set(mevcut);
|
||||
if (yeni.has(sira)) yeni.delete(sira);
|
||||
else yeni.add(sira);
|
||||
return yeni;
|
||||
});
|
||||
}
|
||||
|
||||
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
|
||||
const iller = new Set(
|
||||
rapor.tercihler
|
||||
.map((t) => rapor.programlar[t.programId]?.il)
|
||||
.filter((il): il is string => Boolean(il)),
|
||||
);
|
||||
const tekIl = iller.size === 1 ? [...iller][0] : null;
|
||||
|
||||
const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
const analizKilitli = index >= kilitIdx;
|
||||
const risk =
|
||||
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
||||
dilimdenRisk(t.dilim);
|
||||
const stil = RISK_STIL[risk];
|
||||
const seri = raporSiraSerisi(p?.siraGecmisi);
|
||||
const taban = p?.efektifSira ?? programEtkinSira(seri);
|
||||
const acikMi = acik.has(t.sira);
|
||||
const devlet = p?.unitur === "DEVLET";
|
||||
|
||||
return (
|
||||
<Fragment key={t.sira}>
|
||||
<TableRow
|
||||
ref={(el) => {
|
||||
if (el) satirRefs.current.set(t.sira, el);
|
||||
else satirRefs.current.delete(t.sira);
|
||||
}}
|
||||
onClick={() => tiklandi(t.sira)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<TableCell className="pl-4 pr-0">
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
||||
{t.sira}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{p?.universite ? <UniLogo ad={p.universite} boy="sm" /> : null}
|
||||
<span className="min-w-0 flex-1">
|
||||
{p ? (
|
||||
<Link
|
||||
href={`/bolum/${bolumSayfaSlug(p.isim)}`}
|
||||
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title={`${p.isim} bölüm sayfası`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{p.isim}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{t.programId}
|
||||
</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 ? (
|
||||
<Link
|
||||
href={`/universite/${uniSayfaSlug(p.universite)}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
title={`${p.universite} taban puanları sayfası`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{p.universite}
|
||||
</Link>
|
||||
) : null}
|
||||
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-xs md:table-cell">
|
||||
<ProgramSiraGecmisi program={seri} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium">
|
||||
{analizKilitli ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-slate-500">
|
||||
<Lock className="size-3.5 shrink-0 text-slate-400" aria-hidden />
|
||||
<span className="sr-only sm:not-sr-only">Pakette</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only sm:not-sr-only">
|
||||
{RISK_ETIKET[risk]}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ProgramTabanTrendi program={seri} />
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={acikMi}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
tiklandi(t.sira);
|
||||
}}
|
||||
className="inline-flex size-8 min-h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition-[background-color,border-color,color,transform] duration-150 hover:border-primary hover:bg-primary/5 hover:text-primary active:scale-[0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{p?.isim ?? t.programId} — Yapay Zeka değerlendirmesi
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{acikMi ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={SUTUN_SAYISI}
|
||||
className="whitespace-normal bg-slate-50/60 px-4 py-4"
|
||||
>
|
||||
{analizKilitli ? (
|
||||
<>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
<span className="font-medium">Kişisel analiz pakette</span>
|
||||
{p?.unitur ? <span>· {p.unitur}</span> : null}
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-3">
|
||||
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
Yapay Zeka karar desteği
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Bu bölümün neden listende olduğunu ve sıralamana özel
|
||||
yerleşme riskini paketle gör.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<RaporSatirDetayi
|
||||
dilim={t.dilim}
|
||||
risk={risk}
|
||||
unitur={p?.unitur}
|
||||
gerekce={t.gerekce}
|
||||
riskNotu={t.riskNotu}
|
||||
trendOzeti={t.trendOzeti}
|
||||
/>
|
||||
)}
|
||||
{onHaritadaGor && p?.il ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHaritadaGor(p.il, t.sira)}
|
||||
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
||||
>
|
||||
<MapPin className="size-3.5" aria-hidden />
|
||||
Haritada gör
|
||||
</button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{kilitIdx < rapor.tercihler.length ? (
|
||||
<p className="text-right text-xs font-medium text-emerald-700">
|
||||
Programlar ve ham veriler ücretsiz
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rapor.kapsam?.uyarlandi ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Tercihlerine göre {rapor.kapsam.toplam} uygun program bulundu; listen{" "}
|
||||
{rapor.kapsam.dagilim.hayal} hayal · {rapor.kapsam.dagilim.dengeli}{" "}
|
||||
dengeli · {rapor.kapsam.dagilim.garanti} garanti olarak kuruldu. Daha
|
||||
fazla seçenek için alan veya il filtreni genişletebilirsin.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tekIl ? (
|
||||
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Listenin {rapor.tercihler.length} tercihi de tek şehirde (
|
||||
{tekIl.toLocaleLowerCase("tr-TR")}). Boşta kalma riskini azaltmak için
|
||||
birkaç farklı şehir eklemeyi düşünebilirsin.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8 pl-4 pr-0">#</TableHead>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead className="hidden text-right md:table-cell">
|
||||
Son 5 yıl
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="hidden sm:inline">Yapay Zeka </span>Risk
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Taban sıra</TableHead>
|
||||
<TableHead className="w-12 pr-4 text-right">
|
||||
<span className="sr-only">Yapay Zeka değerlendirmesi</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rapor.tercihler.map((t, index) => (
|
||||
<Fragment key={t.sira}>
|
||||
{index === kilitIdx && kilitOverlay ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={SUTUN_SAYISI}
|
||||
className="whitespace-normal border-y border-orange-200 bg-orange-50/70 p-5"
|
||||
>
|
||||
{kilitOverlay}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{satir(t, index)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/features/rapor/components/rapor-yazdir.tsx
Normal file
153
src/features/rapor/components/rapor-yazdir.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifySession, getCurrentUser } from "@/lib/session";
|
||||
import { kullanicininRaporu } from "../rapor-queries";
|
||||
import { PUAN_TURLERI } from "@/types/yokatlas";
|
||||
import type { RaporParams, RaporSonuc } from "../types/rapor";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { YazdirButonu } from "./yazdir-butonu";
|
||||
|
||||
const DILIM_LABEL: Record<string, string> = {
|
||||
hayal: "Hayal (Riskli)",
|
||||
dengeli: "Dengeli (Az riskli)",
|
||||
garanti: "Garanti (Güvenli)",
|
||||
};
|
||||
|
||||
export async function RaporYazdir() {
|
||||
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",
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Rapor sorgusu beklenirken belge düzeninde iskelet.
|
||||
export function RaporYazdirSkeleton() {
|
||||
return (
|
||||
<div 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/features/rapor/components/revizyon-kutusu.tsx
Normal file
100
src/features/rapor/components/revizyon-kutusu.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"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 "@/features/rapor/rapor-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>
|
||||
);
|
||||
}
|
||||
417
src/features/rapor/components/sohbet-client.tsx
Normal file
417
src/features/rapor/components/sohbet-client.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
170
src/features/rapor/components/tadimlik-satiri.tsx
Normal file
170
src/features/rapor/components/tadimlik-satiri.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"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 "./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>
|
||||
);
|
||||
}
|
||||
22
src/features/rapor/components/yazdir-butonu.tsx
Normal file
22
src/features/rapor/components/yazdir-butonu.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user