Update package.json scripts, enhance layout with new components, and improve API error handling
Some checks failed
Deploy / deploy (push) Failing after 1h28m6s

- Added new scripts for database operations and temporary production in package.json.
- Integrated KaydetBannerLazy component into the layout for improved user notifications.
- Enhanced API error handling in the soru route to mark credit exhaustion.
- Updated the IletisimPage for better button styling and user experience.
- Refactored ListemPage to streamline user flow and improve session handling.
- Removed unused ListePaneli component to clean up the codebase.
This commit is contained in:
bilalgursen
2026-08-06 02:27:42 +03:00
parent 488845ae1f
commit e62c6f07df
77 changed files with 4132 additions and 1659 deletions

View File

@@ -0,0 +1,19 @@
import { NextResponse, type NextRequest } from "next/server";
import { netlerGetir } from "@/lib/db";
// Program satırındaki "netler" butonunun verisi: programa yerleşen son
// kişinin yıl bazlı netleri (bkz. components/program-netleri.tsx). Veri
// yılda bir değiştiği için istemci tarafında gönül rahatlığıyla cache'lenir.
export function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get("id") ?? "";
if (!/^\d{1,12}$/.test(id)) {
return NextResponse.json(
{ error: "Geçerli bir program kimliği gerekli." },
{ status: 400 },
);
}
return NextResponse.json(
{ netler: netlerGetir(id) },
{ headers: { "Cache-Control": "public, max-age=86400" } },
);
}

View File

@@ -2,7 +2,11 @@ import { eq, desc, asc } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { spendCreditForMessage, grantCredits } from "@/lib/credits";
import {
spendCreditForMessage,
grantCredits,
krediBittiIsaretle,
} from "@/lib/credits";
import { anahtarVar, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { sohbetAkisi } from "@/lib/ai/cagri";
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
@@ -147,6 +151,7 @@ export async function POST(request: NextRequest) {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(session.user.id);
return Response.json(
{ error: "Kredin bitti.", code: "INSUFFICIENT" },
{ status: 402 },

View File

@@ -0,0 +1,38 @@
// Tadımlık satırı servisi: client sihirbaz kategorisini (localStorage'da
// yaşar, SSR göremez) bildirir; SEÇİM sunucuda yapılır (PRD kısıtı). Havuz
// batch üretilmiştir — bu uç LLM'e istek atmaz, salt SELECT'tir.
import { NextResponse } from "next/server";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import { KATEGORILER } from "@/lib/kategoriler";
import { kategoriSlugBul, tadimlikSec } from "@/lib/tadimlik-havuzu";
const GECERLI_KATEGORI_SLUGLARI = new Set(KATEGORILER.map(kategoriSlugBul));
export async function GET(istek: Request) {
const url = new URL(istek.url);
const sira = Number.parseInt(url.searchParams.get("sira") ?? "", 10);
const tur = url.searchParams.get("tur") ?? "";
if (
!Number.isFinite(sira) ||
sira < 1 ||
sira > 4_000_000 ||
!(tur in PUAN_TURLERI)
) {
return NextResponse.json({ tadimlik: null }, { status: 400 });
}
const kategoriParam = url.searchParams.get("kategori");
const kategoriSlug =
kategoriParam && GECERLI_KATEGORI_SLUGLARI.has(kategoriParam)
? kategoriParam
: undefined;
const tadimlik = await tadimlikSec(sira, tur as PuanTuruKey, kategoriSlug);
return NextResponse.json(
{ tadimlik },
// Satırlar kova granülaritesinde statik; 1 saat cache yeterli.
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}

View File

@@ -25,7 +25,7 @@ export default function IletisimPage() {
</p>
<a
href="mailto:destek@kolaytercih.com"
className="mt-6 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
className="mt-6 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-900 shadow-xs transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<Mail className="size-4 text-primary" aria-hidden />
destek@kolaytercih.com

View File

@@ -6,6 +6,7 @@ import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
import { KaydetBannerLazy } from "@/components/manuel-liste/kaydet-banner-lazy";
import { TercihProfiliKapisiLazy } from "@/components/manuel-liste/tercih-profili-kapisi-lazy";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
@@ -124,6 +125,8 @@ export default function RootLayout({
</ScrollFlow>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesiLazy />
{/* Girişsiz + 3 seçimli kullanıcıya "listen yalnızca bu tarayıcıda" banner'ı */}
<KaydetBannerLazy />
{/* İlk "+" için sıra + sihirbaz kapısı */}
<TercihProfiliKapisiLazy />
<Toaster position="top-center" />

View File

@@ -0,0 +1,208 @@
"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,
tercihProfiliOku,
tercihProfiliSil,
type TercihProfili,
} from "@/lib/sihirbaz";
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>
);
}

View File

@@ -26,7 +26,7 @@ export function ListemBosCta() {
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white hover:bg-orange-600"
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"}

View 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 "@/components/rapor-listesi";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { TercihHaritasi } from "@/components/tercih-haritasi";
import { pinleriTuret } from "@/lib/harita-pinler";
import type { RaporSonuc } from "@/lib/ai/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ıı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
ı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 {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 ı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
<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>
);
}

View File

@@ -1,33 +1,19 @@
import type { Metadata } from "next";
import Link from "next/link";
import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import {
ArrowRight,
Bot,
Check,
FileText,
Lock,
MessageCircleQuestion,
PencilLine,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { MessageCircleQuestion, Sparkles } from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import { URUNLER, MAX_REVIZYON } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
import { RaporListesi } from "@/components/rapor-listesi";
import { ViewportPortal } from "@/components/viewport-portal";
import { SiteFooter } from "@/components/site-footer";
import { SectionEyebrow } from "@/components/pixel-decor";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
import { raporMaskele, ACIK_SATIR, type MaskeliRapor } 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";
// listeRevize aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
@@ -46,9 +32,13 @@ const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
export default async function ListemPage({
searchParams,
}: {
searchParams: Promise<{ acildi?: string }>;
searchParams: Promise<{ acildi?: string; uret?: string }>;
}) {
const session = await verifySession("/listem");
// 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,
@@ -66,46 +56,60 @@ export default async function ListemPage({
.orderBy(asc(schema.chatMessages.createdAt))
.limit(100),
]);
if (!user) redirect("/giris?callback=/listem");
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">
<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>
{/* ?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 = user.hasPaket
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ıık, kalanının yalnızca sayısı var.
const danismanOzeti = rapor.genelDegerlendirme
? {
metin: rapor.genelDegerlendirme,
uyarilar: user.hasPaket ? rapor.uyarilar : undefined,
uyarilar: rapor.uyarilar,
kilitli: !user.hasPaket,
kilitliUyariSayisi: user.hasPaket ? undefined : rapor.kilitliUyariSayisi,
}
: undefined;
return (
<>
<main className="mx-auto w-full max-w-4xl px-4 py-12">
<main className="mx-auto w-full max-w-7xl px-4 py-12">
{kilitAcilisi ? (
<div
role="status"
@@ -116,184 +120,21 @@ export default async function ListemPage({
artık ık.
</div>
) : null}
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
{user.hasPaket ? (
<SectionEyebrow className="mb-3">Paketin aktif</SectionEyebrow>
) : null}
<h1 className="max-w-2xl font-heading text-xl font-bold tracking-tight sm:text-2xl">
{user.hasPaket
? "Yapay Zeka tercih listen"
: "Veriyi ücretsiz incele. Kararı Yapay Zeka ile netleştir."}
</h1>
{user.hasPaket ? (
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{raporParams.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[raporParams.tur as keyof typeof PUAN_TURLERI] ??
raporParams.tur}{" "}
· 24 tercihinin tamamını gerekçe, risk analizi ve trendleriyle
aşağıda inceleyebilirsin.
</p>
) : null}
</div>
{user.hasPaket ? (
<div className="flex 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>
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-w-0 flex-col gap-6">
<section
className={`min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5${
kilitAcilisi ? " kt-unblur" : ""
}`}
>
<RaporListesi
rapor={rapor}
adaySira={raporParams.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<div 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 {ACIK_SATIR} tercihte Yapay Zeka analizini gördün
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Kalan {24 - ACIK_SATIR} tercihin kişisel risk, gerekçe ve
Yapay Zeka yorumu paketle ı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 {PAKET_FIYATI} TL
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div>
}
/>
</section>
{user.hasPaket ? (
<RevizyonKutusu
kalanHak={Math.max(0, MAX_REVIZYON - (satir!.revisionCount ?? 0))}
/>
) : null}
</div>
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
{user.hasPaket ? (
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti}
/>
) : (
<div className="flex flex-col gap-6">
<section 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 ı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",
`${URUNLER.paket.credits} 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">
{PAKET_FIYATI} TL
</p>
</div>
<Button
asChild
size="lg"
className="mt-5 h-12 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
<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>
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
ozet={danismanOzeti}
className="h-[520px]"
/>
</div>
)}
</div>
</div>
{/* 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

View File

@@ -3,7 +3,8 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AlertTriangle, ArrowUp, Bot, Coins, Lock, Sparkles } from "lucide-react";
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";
@@ -35,6 +36,26 @@ 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 = [
@@ -157,7 +178,7 @@ export function SohbetClient({
aria-label="Danışman sohbeti"
className={cn(
"flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white",
className ?? "h-[calc(100dvh-9rem)] min-h-[480px]",
className ?? "h-[calc(100dvh-16rem)] min-h-[400px]",
)}
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
@@ -177,9 +198,9 @@ export function SohbetClient({
<ChatContainerContent className="gap-4 p-4">
{ozet ? (
<Message className="justify-start">
<DanismanAvatar />
<div className="flex max-w-[85%] flex-col gap-2">
<p className="flex items-center gap-1.5 text-xs font-semibold text-slate-500">
<Bot className="size-3.5 text-primary" aria-hidden />
<p className="text-xs font-semibold text-slate-500">
Danışman özeti
</p>
<MessageContent className="bg-slate-100 text-slate-800">
@@ -204,8 +225,17 @@ export function SohbetClient({
{ozet.kilitli ? (
<p className="flex items-center gap-1.5 text-xs text-slate-500">
<Lock className="size-3.5" aria-hidden />
Değerlendirmenin tamamı ve sıralamana özel uyarılar
pakette.
{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>
@@ -281,6 +311,7 @@ export function SohbetClient({
</Message>
) : (
<Message key={m.id} className="justify-start">
<DanismanAvatar />
{m.content ? (
<MessageContent
markdown

View File

@@ -7,6 +7,7 @@ import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import {
spendCredits,
grantCredits,
krediBittiIsaretle,
RAPOR_KREDI,
MAX_REVIZYON,
} from "@/lib/credits";
@@ -113,6 +114,7 @@ export async function listeOlustur(input: {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",
@@ -241,6 +243,7 @@ export async function listeRevize(input: {
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
await krediBittiIsaretle(user.id);
return {
ok: false,
code: "KREDI",

View File

@@ -1,401 +0,0 @@
"use client";
import { useMemo, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, FileText, Lock, RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SohbetPopup } from "./sohbet-popup";
import { RaporListesi } from "@/components/rapor-listesi";
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { dilimdenRisk, riskHesapla, type RiskSeviyesi } from "@/lib/risk";
const MAX_REVIZYON = 2;
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
/** Panelin liste dışındaki geçici durumları (liste, rapor varlığından türer). */
export type PanelDurum = "bos" | "uretiliyor" | "kredi-yetersiz";
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
guvenli: 0,
"az-riskli": 1,
riskli: 2,
};
/**
* Rapordan harita pinleri türetir. Açık satırlar üniversite bazında gruplanır
* (kampüs konumunda, adıyla); kilitli satırlar il başına tek adsız "?" pinine
* toplanır — maskeli veride üniversite adı zaten client'a inmediği için
* harita paywall'ı delemez. Haritada karşılığı olmayan tercihler sayılır.
*/
function pinleriTuret(
rapor: RaporSonuc,
adaySira: number,
kilitliBaslangic: number,
): { pinler: HaritaPin[]; haritaDisi: number } {
const gruplar = new Map<string, HaritaPin>();
let haritaDisi = 0;
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic;
if (kilitli) {
const konum = ilKonum(p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `kilit|${p.il}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
// Açık pinlerle üst üste binmesin diye il merkezinin hafif altına
gruplar.set(key, {
id: key,
universite: "",
il: p.il,
x: konum.x,
y: konum.y + 9,
tercihSiralari: [t.sira],
risk,
kilitli: true,
});
}
return;
}
const konum = uniKonum(p.universite, p.il);
if (!konum) {
haritaDisi += 1;
return;
}
const key = `${uniAdiNormalize(p.universite)}|${p.il ?? ""}`;
const mevcut = gruplar.get(key);
if (mevcut) {
mevcut.tercihSiralari.push(t.sira);
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
} else {
gruplar.set(key, {
id: key,
universite: p.universite,
il: p.il,
x: konum.x,
y: konum.y,
tercihSiralari: [t.sira],
risk,
});
}
});
return { pinler: [...gruplar.values()], haritaDisi };
}
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…",
];
/**
* Sonuç sayfası gövdesindeki liste paneli: üretim animasyonu, paketli tam
* liste veya paketsiz progressive-blur önizleme burada render edilir.
* (Sihirbaz modalı yalnızca adımları toplar; sonuç artık sayfanın kendisinde.)
*/
export function ListePaneli({
durum,
rapor,
hasPaket,
kredi,
sira,
acilis,
onRevize,
}: {
durum: PanelDurum;
rapor: MevcutRapor | null;
hasPaket: boolean;
kredi: number;
sira: number;
/** Ödeme dönüşü: blur çözülme animasyonuyla aç */
acilis?: boolean;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const kilitli = !hasPaket;
// 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<HTMLDivElement>(null);
const pinVerisi = useMemo(
() =>
rapor
? pinleriTuret(
rapor.rapor,
rapor.params.sira,
kilitli ? ACIK_SATIR : rapor.rapor.tercihler.length,
)
: null,
[rapor, kilitli],
);
if (durum === "uretiliyor") return <UretimGovdesi />;
if (durum === "kredi-yetersiz") {
// Paketli kullanıcıya paket satma: onun ürünü top-up. Liste ve sohbet
// geçmişi kalıcı; kredi yükleyince kaldığı yerden devam eder.
return (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<Lock className="mx-auto size-8 text-slate-400" aria-hidden />
<h2 className="mt-4 font-heading text-xl font-bold">
Kredin bu liste için yetmiyor
</h2>
<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 h-12 cursor-pointer bg-orange-500 px-8 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 (!rapor) return null;
return (
<section
className={`mt-16 ${acilis && !kilitli ? "kt-unblur" : ""}`}
aria-label="Tercih listen"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-bold">
24 Tercihlik Listen
</h2>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
{kilitli
? `tüm taban verileri açık · ${ACIK_SATIR} Yapay Zeka analiz örneği`
: `${kredi} kredin kaldı`}
</p>
</div>
{!kilitli ? (
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
) : null}
</div>
{/* Harita: 24 tercihin coğrafyası — ile dokun, üniversiteleri gör */}
<div ref={haritaRef} className="mt-5 scroll-mt-24">
<TercihHaritasi
pinler={pinVerisi?.pinler ?? []}
seciliIl={seciliIl}
onSeciliIlDegisti={setSeciliIl}
onTercihSec={setAcikSira}
/>
{pinVerisi && pinVerisi.haritaDisi > 0 ? (
<p className="mt-1 px-1 text-xs text-slate-500">
{pinVerisi.haritaDisi} tercih harita dışında (KKTC vb.)
</p>
) : null}
</div>
<div className="mt-5">
<RaporListesi
rapor={rapor.rapor}
adaySira={rapor.params.sira}
acikSira={acikSira}
onHaritadaGor={(il) => {
if (!il) return;
setSeciliIl(il);
haritaRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}}
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
kilitOverlay={
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex gap-3">
<Lock
className="mt-1 size-5 shrink-0 text-orange-700"
aria-hidden
/>
<div>
<p className="font-heading font-bold text-slate-900">
Tüm program ve taban verileri ücretsiz
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
gerekçe, PDF, 2 revizyon ve 60 Yapay Zeka danışman sorusunu açar.
</p>
{kredi > 0 ? (
<p className="mt-1 text-xs text-slate-500">
Kalan {kredi} deneme kredin hesabında duruyor.
</p>
) : null}
</div>
</div>
<Button
asChild
size="lg"
className="h-12 w-full shrink-0 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
>
<Link href="/paket">299 TL Analizleri </Link>
</Button>
</div>
}
/>
</div>
{!kilitli ? (
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={onRevize}
/>
</div>
) : null}
{/* Yapay Zeka danışman: sağ altta yüzen buton, alttan popup olarak açılır */}
<SohbetPopup kredi={kredi} />
</section>
);
}
function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0);
const govdeRef = useRef<HTMLElement>(null);
useEffect(() => {
const interval = setInterval(() => {
setMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
return () => clearInterval(interval);
}, []);
// Modal geçiş anında unmount edildiğinden Radix odağı tetikleyiciye geri
// veremiyor; odağı panele alıp klavye ve ekran okuyucu akışını sürdürüyoruz.
useEffect(() => {
govdeRef.current?.focus({ preventScroll: true });
}, []);
return (
<section
ref={govdeRef}
tabIndex={-1}
aria-live="polite"
className="mt-16 rounded-2xl border border-slate-200 bg-white p-6 outline-none sm:p-8"
>
<div className="flex flex-col items-center gap-1.5 text-center">
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<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>
);
}
function RevizyonKutusu({
kalanHak,
onRevize,
}: {
kalanHak: number;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
if (kalanHak <= 0) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<p className="text-sm text-slate-500">
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
</p>
</div>
);
}
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<h3 className="font-heading text-base font-bold">
Listede değişiklik mi istiyorsun?
</h3>
<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={async () => {
setPending(true);
const ok = await onRevize(feedback.trim());
setPending(false);
if (ok) setFeedback("");
}}
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>
</div>
);
}

View File

@@ -6,23 +6,16 @@ import {
searchByRank,
type PuanTuruKey,
} from "@/lib/db";
import { getSession, getCurrentUser } from "@/lib/session";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { raporMaskele } from "@/lib/rapor-maske";
import { tadimlikSec } from "@/lib/tadimlik-havuzu";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type { MevcutRapor } from "./liste-paneli";
import { TadimlikSatiri } from "./tadimlik-satiri";
import { ProgramTablosu } from "@/components/program-tablosu";
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
import { SiteFooter } from "@/components/site-footer";
import { SiraGerekli } from "./sira-gerekli";
// listeOlustur aksiyonu 2 AI denemesi yapabiliyor (deneme başına 50 sn abort
// sınırı, bkz. lib/ai/cagri.ts); platform limiti AI zaman aşımından SONRA
// dolmalı ki kredi iadesi her zaman çalışabilsin.
export const maxDuration = 120;
export const metadata: Metadata = {
title: "Tercih Listeni Kur",
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
@@ -38,7 +31,6 @@ export default async function SonucPage({
sira?: string;
tur?: string;
sihirbaz?: string;
acildi?: string;
hazir?: string;
}>;
}) {
@@ -58,34 +50,25 @@ export default async function SonucPage({
);
}
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
// Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
// beklemeden paralel çalışır (seri DB zinciri kurma).
// Sihirbaz için: kullanıcının kayıtlı raporu var mı? Listenin kendisi
// /listem'de yaşar; burada yalnızca CTA'nın hangi hâlde render edileceği
// için varlık bilgisi gerekir.
const session = await getSession();
const [user, raporSatiri] = session
? await Promise.all([
getCurrentUser(),
appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
}),
])
: [null, null];
const raporSatiri = session
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
columns: { result: true },
})
: null;
const facetler = rankWindowFacets(sira, turKey);
// Yapay Zeka'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
const hamSonuclar = searchByRank(sira, turKey, { limitPerBucket: 20 });
// Paketsize giden rapor sunucuda maskelenir: blur yalnızca CSS olduğundan
// kilitli satırların gerçek içeriği client'a hiç inmez.
const hasPaket = Boolean(user?.hasPaket);
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
? {
rapor: hasPaket
? (raporSatiri.result as RaporSonuc)
: raporMaskele(raporSatiri.result as RaporSonuc),
params: raporSatiri.params as RaporParams,
revisionCount: raporSatiri.revisionCount,
}
: null;
// Girişsiz tadımlık (İP-B): SSR'da kovaya uyan "genel" satır seçilir;
// sihirbaz kategorisi client'ta okunup /api/tadimlik ile kişiselleştirilir.
// Girişli kullanıcı gerçek ürünü zaten görebildiği için tadımlık almaz.
const tadimlik = session ? null : await tadimlikSec(sira, turKey);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
@@ -99,18 +82,21 @@ export default async function SonucPage({
gövdesi; puan türü navbar'daki sıralama rozetinden değiştirilir */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
{/* Sihirbaz akışı (CTA + modal + Yapay Zeka listesi) — blurlu Yapay Zeka paneli
sayfanın en altında yaşar */}
{/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek.
Havuzda içerik yoksa bileşen hiç render edilmez. */}
{!session ? (
<TadimlikSatiri sira={sira} tur={turKey} ilk={tadimlik} />
) : null}
{/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar,
burada yalnızca funnel kartları render edilir */}
<SihirbazBolumu
sira={sira}
tur={turKey}
facetler={facetler}
girisliMi={Boolean(session)}
hasPaket={hasPaket}
kredi={user?.creditBalance ?? 0}
mevcutRapor={mevcutRapor}
raporVar={Boolean(raporSatiri?.result)}
otomatikAc={params.sihirbaz === "1"}
acilis={params.acildi === "1"}
secimlerHazir={params.hazir === "1"}
/>

View File

@@ -3,29 +3,26 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, X } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { animate } from "motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/ui/button";
import { ViewportPortal } from "@/components/viewport-portal";
import { PixelField } from "@/components/pixel-decor";
import { useGorunumOlayi } from "@/components/use-gorunum-olayi";
import { olay } from "@/lib/analitik";
import type { SihirbazFacetleri } from "@/lib/db";
import {
SIHIRBAZ_AC_EVENT,
SIHIRBAZ_STORAGE_KEY,
sihirbazDogrula,
sonucHref,
tercihProfiliSil,
tercihProfiliYaz,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import { olay, siraKovasi } from "@/lib/analitik";
import { SihirbazModal } from "./sihirbaz-modal";
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
// Üretim /listem'de koşar (bkz. listem/liste-uretici.tsx); sihirbaz
// tamamlanınca seçimler storage'a yazılıp kullanıcı buraya yönlendirilir.
const LISTEM_URET_HREF = "/listem?uret=1";
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
const bosAbone = () => () => {};
@@ -43,42 +40,35 @@ type BekleyenSecim = {
/**
* Sonuç sayfasında sihirbaz akışını yöneten client orkestratör:
* - Sayfa ortasındaki büyük CTA + sabit alt çağrı kartı (rapor yokken)
* - Sayfa ortasındaki büyük CTA (rapor yokken)
* - Adımları toplayan modal
* - Üretim/önizleme/liste paneli (sayfa gövdesinde)
* Akış: adımlar → kredi ile üretim → paketsizse progressive-blur önizleme,
* paketliyse tam liste. Girişsiz kullanıcı hero'dan seçimleri tamamlanmış
* gelirse önce ücretsiz tabloyu görür; giriş CTA'sı "hazır seçimlerin
* kaydedilmesi" olarak panelde sunulur, girişten dönüşte üretim otomatik
* başlar. Modal içinden ilerleyen girişsiz kullanıcı ise butonun söylediği
* gibi doğrudan girişe gider.
* Liste bu sayfada üretilmez ve gösterilmez: sihirbaz tamamlanınca seçimler
* storage'a yazılır ve kullanıcı /listem'e yönlendirilir; üretim animasyonu,
* kredi/hata durumları ve listenin kendisi orada yaşar. Girişsiz kullanıcı
* hero'dan seçimleri tamamlanmış gelirse önce ücretsiz tabloyu görür; giriş
* CTA'sı "hazır seçimlerin kaydedilmesi" olarak panelde sunulur, girişten
* dönüş doğrudan /listem'e düşer ve üretim orada başlar.
*/
export function SihirbazBolumu({
sira,
tur,
facetler,
girisliMi,
hasPaket,
kredi: baslangicKredi,
mevcutRapor,
raporVar,
otomatikAc,
acilis,
secimlerHazir,
}: {
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
/** Kullanıcının kayıtlı bir Yapay Zeka listesi var mı (listenin kendisi /listem'de) */
raporVar: boolean;
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean;
/** ?acildi=1 (ödeme dönüşü): blur çözülme animasyonuyla aç */
acilis?: boolean;
/** ?hazir=1 (hero sihirbazından girişsiz geliş): seçimler storage'da,
panel giriş CTA'sına döner. URL'den gelir ki SSR'da doğru panel
render edilsin — mount sonrası anahtar değişimi animasyonu donduruyor. */
render edilsin. */
secimlerHazir?: boolean;
}) {
const router = useRouter();
@@ -87,36 +77,19 @@ export function SihirbazBolumu({
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
const [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
// ?sihirbaz=1 fallback modalı storage okunmadan açılmasın: hero'dan gelen
// kayıtlı seçimler varken modal bir anlığına açılıp kapanıyordu.
const [depoOkundu, setDepoOkundu] = useState(false);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [kredi, setKredi] = useState(baslangicKredi);
const [paketli, setPaketli] = useState(hasPaket);
const [durum, setDurum] = useState<PanelDurum>("bos");
// Adımlarda yapılan son seçimler (hata sonrası modala dönüşte prefill)
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
null,
);
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
// Revizyon için aynı desen (bkz. listem/revizyon-kutusu.tsx)
const revizyonIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Funnel CTA'ları (banner olayı dahil) tıklama anındaki bekleyen seçimi
// görsün diye ref'te tutulur; olay dinleyicisi bir kez bağlanır.
const bekleyenRef = useRef<BekleyenSecim | null>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
// Mount sonrası storage'dan bekleyen seçimleri oku
useEffect(() => {
try {
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
let yeniBekleyen: BekleyenSecim | null = null;
if (ham) {
@@ -133,9 +106,8 @@ export function SihirbazBolumu({
};
}
}
// eslint-disable-next-line react-hooks/set-state-in-effect
if (kaliciKapali) setKartGizle(true);
if (yeniBekleyen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setBekleyen(yeniBekleyen);
bekleyenRef.current = yeniBekleyen;
}
@@ -151,19 +123,6 @@ export function SihirbazBolumu({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Bölüm ekran dışındaysa (kullanıcı sihirbazı üstteki banner'dan ya da
* sabit alt karttan açmış olabilir) modal hâlâ perdeyi kaplarken kaydırırız;
* böylece Motion ile giren hedef panel görünür alanda kalır.
*/
function bolumuGorunureAl() {
const el = bolumRef.current;
if (!el) return;
const kutu = el.getBoundingClientRect();
if (kutu.top >= 0 && kutu.bottom <= window.innerHeight) return;
el.scrollIntoView({ block: "center", behavior: "instant" });
}
async function modalCikisiniOynat() {
if (hareketiAzalt) return;
@@ -202,85 +161,26 @@ export function SihirbazBolumu({
await modalAnimasyonu;
}
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
bolumuGorunureAl();
/**
* Sihirbaz tamamlandı: seçimleri storage'a yaz ve üretimin koştuğu
* /listem'e geç. Girişsiz kullanıcı önce girişe uğrar; callback doğrudan
* /listem'e düştüğünden üretim dönüşte orada otomatik başlar.
*/
async function ilerle(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
await modalCikisiniOynat();
setElleAcik(false);
setDurum("uretiliyor");
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
olay("liste_olusturuldu", { tur, sira_kovasi: siraKovasi(sira) });
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setPaketli(sonuc.hasPaket);
tercihProfiliSil();
// Yapay Zeka çıktısı bu sayfada gösterilmez: üretim animasyonu açık kalır,
// liste (paketsizse paywall'lı hâliyle) /listem'de karşılar.
router.push("/listem");
// Header'daki kredi pill'i layout'ta server-render edilir; push layout'u
// yeniden çizmediği için düşen bakiye refresh ile güncellenir.
router.refresh();
return;
}
// Sunucudan cevap geldiyse harcama ya hiç olmadı ya iade edildi;
// sonraki deneme taze requestId ile başlar (aynı id ledger'daki UNIQUE'e
// takılıp DUPLICATE çıkmazına giriyordu). requestId yalnızca cevap
// alınamayan network hatasında (catch) korunur — orada çifte harcama
// riski gerçek.
requestIdRef.current = null;
if (sonuc.code === "KREDI" || sonuc.code === "PAKET") {
setDurum("kredi-yetersiz");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
modalaDon();
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
modalaDon();
}
}
/** Hata sonrası panelden modala dönüş. */
function modalaDon() {
setDurum("bos");
setElleAcik(true);
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
tercihProfiliYaz({ sira, tur, secimler });
const geri = sonucHref({ sira, tur }, { sihirbaz: "1" });
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
async function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
await modalCikisiniOynat();
secimleriKaydetVeGirise(secimler);
router.push(`/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`);
return;
}
await uret(secimler);
router.push(LISTEM_URET_HREF);
}
/**
* Funnel CTA'larının (banner, büyük kart, sabit alt kart) ortak girişi:
* Funnel CTA'larının (banner, büyük kart) ortak girişi:
* adımlar zaten tamamlanıp kaydedildiyse modal açılmaz — girişli
* kullanıcıda üretim doğrudan başlar, girişsiz kullanıcı girişe gider.
* kullanıcı doğrudan /listem'e gider, girişsiz kullanıcı girişe gider.
* Kayıtlı seçim yoksa (ya da başka sıra/türe aitse) modal açılır.
*/
function sihirbaziBaslat() {
@@ -293,28 +193,28 @@ export function SihirbazBolumu({
setElleKapandi(false);
}
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
// (bir kez, modal açmadan — üretim sayfa gövdesinde oynar).
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && !rapor);
// Giriş dönüşü (?sihirbaz=1) storage'da eşleşen seçimlerle geldiyse modal
// ılmaz: seçimler hazır, üretim /listem'de başlasın (bir kez).
const otomatikGonder = Boolean(
otomatikAc && bekleyen?.eslesir && girisliMi && !raporVar,
);
useEffect(() => {
if (otomatikUret && bekleyen && !otomatikBasladi.current) {
if (otomatikGonder && !otomatikBasladi.current) {
otomatikBasladi.current = true;
setSonSecimler(bekleyen.secimler);
void uret(bekleyen.secimler);
// replace: geri tuşu bu sayfaya dönünce tekrar /listem'e sekmesin
router.replace(LISTEM_URET_HREF);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [otomatikUret]);
}, [otomatikGonder]);
// ?sihirbaz=1: otomatik üretim devreye girmediyse (seçim kaybolduysa/
// ?sihirbaz=1: otomatik yönlendirme devreye girmediyse (seçim kaybolduysa/
// eşleşmiyorsa) modalı aç ki kullanıcı adımları tamamlayabilsin. Karar
// storage okunmadan verilmez: kayıtlı seçimlerle gelen kullanıcıya modal
// hiç gösterilmez, üretim doğrudan başlar.
// hiç gösterilmez, /listem'e geçilir.
const acik =
!elleKapandi &&
(elleAcik ||
Boolean(
otomatikAc && depoOkundu && !otomatikUret && !rapor && durum === "bos",
));
Boolean(otomatikAc && depoOkundu && !otomatikGonder && !raporVar));
function acikDegisti(v: boolean) {
if (v) {
@@ -326,245 +226,113 @@ export function SihirbazBolumu({
}
}
const panelde = Boolean(rapor) || durum !== "bos";
// Değer önce: sihirbazı bitirmiş ama girişsiz kullanıcı ücretsiz tabloyu
// zaten görüyor; giriş burada bariyer değil, hazır seçimlerin (ödülün)
// kaydedilmesi olarak sunulur. Girişten dönüşte otomatik üretim başlar.
// Sinyal URL'den (?hazir=1) gelir; storage'a bakılsaydı panel anahtarı
// mount sonrası değişir ve AnimatePresence geçişi donardı.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !rapor);
const girisUrl = `/giris?callback=${encodeURIComponent(
sonucHref({ sira, tur }, { sihirbaz: "1" }),
)}`;
const panelAnahtari =
durum !== "bos"
? durum
: rapor
? "rapor-hazir"
: girisBekliyor
? "giris-cta"
: "sihirbaz-cta";
// kaydedilmesi olarak sunulur. Girişten dönüşte üretim /listem'de başlar.
// Sinyal URL'den (?hazir=1) gelir ki SSR'da doğru panel render edilsin.
const girisBekliyor = Boolean(!girisliMi && secimlerHazir && !raporVar);
const girisUrl = `/giris?callback=${encodeURIComponent(LISTEM_URET_HREF)}`;
// Kapı 1 ölçümü: panel yalnızca girisBekliyor dalında render edilir; ref
// başka dalda bağlanmadığından observer da yalnızca o durumda kurulur.
const hazirPanelRef = useGorunumOlayi<HTMLElement>("hazir_panel_goruntulendi");
return (
<>
{/* Yapay Zeka çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
<div ref={bolumRef} className="relative">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={panelAnahtari}
initial={
hareketiAzalt
? { opacity: 0 }
: {
opacity: 0,
transform: "translate3d(0, 8px, 0)",
}
}
animate={{
opacity: 1,
transform: "translate3d(0, 0, 0)",
transition: hareketiAzalt
? { duration: 0.12, ease: "easeOut" }
: {
duration: 0.28,
ease: [0.23, 1, 0.32, 1],
},
}}
exit={
hareketiAzalt
? {
opacity: 0,
transition: { duration: 0.08, ease: "easeOut" },
}
: {
opacity: 0,
transform: "translate3d(0, -4px, 0)",
transition: {
duration: 0.14,
ease: [0.4, 0, 0.7, 0.2],
},
}
}
{/* Yapay Zeka çıktısı (üretim animasyonu dahil) bu sayfada render
edilmez; panel yalnızca funnel CTA'larını gösterir, üretim ve
listenin kendisi /listem'de yaşar. */}
{raporVar ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{durum !== "bos" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
if (!revizyonIdRef.current) {
revizyonIdRef.current = crypto.randomUUID();
}
const sonuc = await listeRevize({
feedback,
requestId: revizyonIdRef.current,
});
revizyonIdRef.current = null;
if (sonuc.ok) {
olay("liste_revize", { sayfa: "sonuc" });
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
toast.success("Listen yeniden kuruldu.");
} else {
toast.error(sonuc.error);
}
return sonuc.ok;
}}
/>
) : rapor ? (
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
24 tercihlik listen hazır
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
seni bekliyor.
</p>
<Button
asChild
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link href={girisUrl}>
5 deneme kredinle Yapay Zeka listeni kur giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi her satırı
yerleşme riski ve gerekçesiyle senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
<ViewportPortal>
{monteEdildi && !panelde && !kartGizle && !acik ? (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
{girisBekliyor
? "Seçimlerin kayıtlı — Yapay Zeka listen hazır bekliyor"
: "Tek tek ekleme, yapay zekâ kursun"}
</p>
<p className="text-xs text-slate-500">
{girisBekliyor
? "Ücretsiz giriş yap, 5 deneme kredinle listen otomatik kurulsun."
: "Üç adımda seçimlerini söyle; risk analizli, gerekçeli 24 tercih hazır."}
</p>
</div>
{girisBekliyor ? (
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={girisUrl}>Giriş yap</Link>
</Button>
) : (
<Button
size="sm"
onClick={sihirbaziBaslat}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Başla
</Button>
)}
<button
type="button"
aria-label="Kapat"
onClick={() => {
setKartGizle(true);
try {
sessionStorage.setItem(KAPATMA_KEY, "1");
} catch {}
}}
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
</button>
</div>
<Link href="/listem">Listeni gör</Link>
</Button>
</section>
) : girisBekliyor ? (
<section
ref={hazirPanelRef}
className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12"
>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={13} />
</div>
) : null}
</ViewportPortal>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
Seçimlerin hazır Yapay Zeka listen bir adım uzakta
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Yukarıdaki tablo her zaman ücretsiz. 5 deneme kredinle 24
tercihlik Yapay Zeka listeni de kurabilirsin: ücretsiz giriş yap,
seçimlerin kayıtlı listen otomatik kurulsun (üretim 3
kredi, kalan 2 kredinle danışmana soru sorarsın).
</p>
<Button
asChild
size="lg"
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
<Link
href={girisUrl}
onClick={() =>
olay("giris_cta_tiklandi", { kaynak: "hazir_panel" })
}
>
5 deneme kredinle Yapay Zeka listeni kur giriş yap
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="relative mt-3 text-xs text-white/70">
Kredi kartı gerekmez · Google ya da e-posta ile 30 saniyede
</p>
</section>
) : (
<section className="relative isolate mt-16 min-w-0 overflow-hidden rounded-2xl bg-primary px-5 py-8 text-center text-white sm:p-12">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 -inset-y-10 -z-10 text-white"
>
<PixelField seed={19} />
</div>
<h2 className="relative font-heading text-2xl font-bold sm:text-3xl">
24 tercihi tek tek elemek yerine, yapay zekâ kursun
</h2>
<p className="relative mx-auto mt-3 max-w-xl text-white/90">
Tablodan elle eklemek her zaman serbest. Ama üç adımda ilgi
alanlarını ve şehirlerini söylersen, yapay zekâ hayal, dengeli
ve garanti dilimlerine dağıtılmış 24 tercihi her satırı
yerleşme riski ve gerekçesiyle senin yerine dizer.
</p>
<Button
size="lg"
onClick={sihirbaziBaslat}
className="relative mt-6 h-auto min-h-12 w-full cursor-pointer whitespace-normal bg-orange-500 px-4 py-3 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto sm:px-8"
>
Yapay Zeka listemi kur
<ArrowRight className="size-4" aria-hidden />
</Button>
</section>
)}
{/* Üretim başladığında modalı kaldır; yeni panelin geçişini Motion
üstlenir ve odağı panel içeriğine taşır. */}
{monteEdildi && durum === "bos" ? (
{monteEdildi ? (
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler}
baslangicSecimler={
sonSecimler ?? (bekleyen?.eslesir ? bekleyen.secimler : null)
}
baslangicSecimler={bekleyen?.eslesir ? bekleyen.secimler : null}
sonButonEtiketi={
girisliMi ? "Yapay Zeka listemi kur (3 kredi)" : "Giriş yap ve listeni gör"
}

View File

@@ -12,7 +12,7 @@ import type { SihirbazSecimleri } from "@/lib/sihirbaz";
/**
* Sihirbaz modalı artık yalnızca adımları toplar; üretim, önizleme ve liste
* sonuç sayfası gövdesinde yaşar (bkz. liste-paneli.tsx). Akış yönetimi
* /listem'de yaşar (bkz. listem/liste-uretici.tsx). Akış yönetimi
* sihirbaz-cagri-karti.tsx'teki SihirbazBolumu'ndadır.
*/
export function SihirbazModal({

View File

@@ -1,120 +0,0 @@
"use client";
// Yapay Zeka danışman sohbeti — sayfa altında yüzen buton + intercom tarzı popup.
// Motion dili: panel tetikleyicisinden doğar (transform-origin sağ-alt),
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Skeleton } from "@/components/ui/skeleton";
import { ViewportPortal } from "@/components/viewport-portal";
function SohbetSkeleton() {
return (
<div
aria-busy="true"
aria-live="polite"
className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
<div className="flex flex-1 flex-col items-center gap-4 px-4 py-10">
<Skeleton className="size-7 rounded-full" />
<Skeleton className="h-5 w-56 max-w-full" />
<div className="w-full max-w-md space-y-2">
<Skeleton className="mx-auto h-3.5 w-full" />
<Skeleton className="mx-auto h-3.5 w-4/5" />
</div>
<div className="flex max-w-lg flex-wrap justify-center gap-2">
{[112, 148, 96, 132].map((w, i) => (
<Skeleton
key={i}
className="h-7 rounded-full"
style={{ width: w }}
/>
))}
</div>
</div>
<div className="border-t border-slate-100 p-3">
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
</div>
);
}
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
// yüklensin — /sonuc ilk yükünü şişirmesin.
const SohbetClient = dynamic(
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
{
ssr: false,
loading: () => <SohbetSkeleton />,
},
);
export function SohbetPopup({ kredi }: { kredi: number }) {
const [acik, setAcik] = useState(false);
const azMotion = useReducedMotion();
useEffect(() => {
if (!acik) return;
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") setAcik(false);
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, [acik]);
return (
<ViewportPortal>
<AnimatePresence>
{acik ? (
<motion.div
key="sohbet"
initial={
azMotion ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.95 }
}
animate={azMotion ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
y: 16,
scale: 0.97,
transition: { duration: 0.18, ease: [0.23, 1, 0.32, 1] },
}
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
style={{ transformOrigin: "bottom right" }}
className="fixed bottom-24 right-4 z-50 h-[min(72dvh,560px)] w-[min(100vw-2rem,420px)] print:hidden"
>
<SohbetClient
baslangicKredi={kredi}
className="h-full shadow-2xl"
/>
</motion.div>
) : null}
</AnimatePresence>
{/* Yüzen tetikleyici — basışta anında küçülür (pointer-down feedback) */}
<button
type="button"
onClick={() => setAcik((v) => !v)}
aria-expanded={acik}
className="fixed bottom-6 right-4 z-50 flex h-13 cursor-pointer items-center gap-2 rounded-full bg-slate-900 py-3 pl-4 pr-5 font-heading text-sm font-bold text-white shadow-xl transition-[transform,background-color] duration-150 hover:bg-slate-700 active:scale-[0.95] print:hidden"
>
{acik ? (
<X className="size-5" aria-hidden />
) : (
<MessageCircleMore className="size-5" aria-hidden />
)}
{acik ? "Kapat" : "Danışmana sor"}
</button>
</ViewportPortal>
);
}

View File

@@ -0,0 +1,173 @@
"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,
tercihProfiliOku,
} from "@/lib/sihirbaz";
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>
);
}