Update UI components and database schema
All checks were successful
Deploy / deploy (push) Successful in 2m38s

- Updated the `page.tsx` component to enhance user experience with new UI elements and improved layout.
- Modified the `funnel-banner.tsx` to include dynamic text and improved styling for better engagement.
- Adjusted the `liste-paneli.tsx` to provide clearer information on user credits and package details.
- Enhanced the `rapor-listesi.tsx` to display additional data points for better insights.
- Updated the database schema in `app.db` to reflect recent changes in the application logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-07-26 05:17:17 +03:00
parent 209d203fe8
commit d93cf742ff
16 changed files with 1062 additions and 621 deletions

View File

@@ -2,14 +2,25 @@ import type { Metadata } from "next";
import Link from "next/link";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { FileText, PencilLine, Sparkles } from "lucide-react";
import {
ArrowRight,
Bot,
Check,
Database,
FileText,
Lock,
PencilLine,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import { URUNLER } from "@/lib/credits";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { Button } from "@/components/ui/button";
import { Lock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
import { SohbetClient } from "./sohbet-client";
@@ -18,6 +29,10 @@ export const metadata: Metadata = {
title: "Listem — KolayTercih",
};
const PAKET_FIYATI = (URUNLER.paket.amountKurus / 100).toLocaleString("tr-TR", {
maximumFractionDigits: 0,
});
export default async function ListemPage() {
await verifySession("/listem");
const user = await getCurrentUser();
@@ -36,8 +51,8 @@ export default async function ListemPage() {
Henüz bir listen yok
</h1>
<p className="mt-3 text-slate-600">
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni
kur; sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
Sıralamanı girip birkaç adımda 24 tercihlik risk analizli listeni kur;
sonra burada görüntüleyip AI danışmanla üzerinde konuşabilirsin.
</p>
<Button
asChild
@@ -57,22 +72,32 @@ export default async function ListemPage() {
const params = satir.params as RaporParams;
return (
<main className="mx-auto w-full max-w-6xl px-4 py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<main className="mx-auto w-full max-w-7xl px-4 py-8 sm:py-10">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="font-heading text-2xl font-bold sm:text-3xl">
Listem
<Badge variant="secondary" className="mb-3">
<Database aria-hidden />
YÖK Atlas verileri ücretsiz
</Badge>
<h1 className="max-w-2xl font-heading text-2xl font-bold tracking-tight sm:text-3xl">
{user.hasPaket
? "Tercih karar merkezin"
: "Veriyi ücretsiz incele. Kararı AI ile netleştir."}
</h1>
<p className="mt-1 text-sm text-slate-600">
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
{params.sira.toLocaleString("tr-TR")}. sıra ·{" "}
{PUAN_TURLERI[params.tur]} · 24 tercih, yerleşme riskine göre
renkli. Sorularını yandaki danışmana sorabilirsin (1 mesaj = 1
kredi).
{PUAN_TURLERI[params.tur]} · 24 programın 20242025 taban
sıralamalarını ve ham trendini aşağıda ücretsiz inceleyebilirsin.
</p>
</div>
{user.hasPaket ? (
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="cursor-pointer">
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link
href={`/sonuc?sira=${params.sira}&tur=${params.tur}&sihirbaz=1`}
>
@@ -80,7 +105,12 @@ export default async function ListemPage() {
Revize et
</Link>
</Button>
<Button asChild variant="outline" size="sm" className="cursor-pointer">
<Button
asChild
variant="outline"
size="sm"
className="cursor-pointer"
>
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF
@@ -90,30 +120,117 @@ export default async function ListemPage() {
) : null}
</div>
<div className="mt-6 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
<RaporListesi
rapor={rapor}
adaySira={params.sira}
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
kilitOverlay={
<>
<Lock className="size-8 text-slate-500" aria-hidden />
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900">
24 tercihin {24 - ACIK_SATIR}&apos;i kilidin ardında
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Tüm listeyi </Link>
</Button>
</>
}
/>
<div className="mt-8 grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_340px]">
<section className="min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5">
<RaporListesi
rapor={rapor}
adaySira={params.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">
Ham veri burada bitmiyor aşağıdaki tüm satırlar ık
</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 ve AI yorumunu açar.
</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
<ArrowRight data-icon="inline-end" aria-hidden />
</Link>
</Button>
</div>
}
/>
</section>
<div className="lg:sticky lg:top-28">
<SohbetClient baslangicKredi={user.creditBalance} />
{user.hasPaket ? (
<SohbetClient baslangicKredi={user.creditBalance} />
) : (
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 p-6">
<div className="flex items-center gap-2 text-primary">
<Sparkles className="size-4" aria-hidden />
<span className="text-xs font-bold uppercase tracking-wide">
Tercih Dönemi Paketi
</span>
</div>
<h2 className="mt-3 font-heading text-xl font-bold tracking-tight">
Tablo ücretsiz.
<br />
Karar desteği pakette.
</h2>
<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 AI yorumu",
"2 liste revizyonu ve paylaşılabilir PDF",
`${URUNLER.paket.credits} AI 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 flex items-end justify-between">
<div>
<p className="text-xs text-slate-500">Tek seferlik</p>
<p className="font-heading text-3xl font-bold">
{PAKET_FIYATI} TL
</p>
</div>
<Badge variant="outline">Abonelik yok</Badge>
</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>
)}
</div>
</div>
</main>

View File

@@ -1,68 +1,140 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ArrowRight, Sparkles } from "lucide-react";
import {
ArrowRight,
ChartNoAxesCombined,
Database,
ListChecks,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation";
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
const CUMLELER = [
"Seçmekte zorlanıyor musunuz?",
"Hangi bölüm size uygun, emin değil misiniz?",
"Yapay zekâ 24 tercihlik listenizi kursun.",
"Her satırda gerekçe ve yerleşme riski analizi.",
const FUNNEL_METINLERI = [
"Sıralamana en uygun listeyi birlikte kuralım.",
"Geleceğin 24 satırlık bir plana sığar.",
"Gerçek YÖK Atlas verisiyle güvenle seç.",
"Her tercihinin bir nedeni olsun.",
"Riskleri biz hesaplayalım, kararı sen ver.",
"Sana uygun bölümler bir adım uzağında.",
"Dengeli bir liste, rahat bir tercih dönemi.",
"Tercihlerini şansa bırakma.",
"Doğru strateji, doğru yerleşme.",
"Hayalindeki bölüm için doğru plan.",
"Verilerle konuşan bir tercih listesi.",
"Üç adımda sana özel 24 tercih.",
"Tercih dönemini stressiz geçir.",
"Geçen yılın verisi, senin avantajın.",
"Güvenli ve iddialı tercihleri dengele.",
"Sıralaman ne söylüyor? Birlikte bakalım.",
"Her satırı senin için hesaplıyoruz.",
"Akıllı tercih, içi rahat bir bekleyiş.",
"Yerleşme ihtimalini görerek seç.",
"Sana özel liste birkaç dakikada hazır.",
"İlgi alanların ve şehrin, tek listede.",
"Boş kalan tercih, kaçan fırsattır.",
"Uzman işi bir liste, senin onayınla.",
"Kararını veriye dayandır.",
"Doğru tercih, doğru bilgiyle başlar.",
"Listeni bugün kur, için rahat olsun.",
"Yerleşme riskin renklerle önünde.",
"En önemli 24 kararını birlikte verelim.",
"Senin sıralaman, sana özel plan.",
"İyi bir liste, iyi bir başlangıçtır.",
];
/** Fisher-Yates ile kopya üzerinde karıştırır; her ziyarette farklı sıra. */
function karistir<T>(dizi: readonly T[]): T[] {
const kopya = [...dizi];
for (let i = kopya.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[kopya[i], kopya[j]] = [kopya[j], kopya[i]];
}
return kopya;
}
const KANITLAR = [
{ ikon: ListChecks, etiket: "24 tercih" },
{ ikon: ChartNoAxesCombined, etiket: "Risk analizi" },
{ ikon: Database, etiket: "YÖK Atlas verisi" },
];
/**
* Puan türü badge'lerinin hemen altında yaşayan satın alım funnel banner'ı.
* Sürekli değişen (yazılıp silinen) cümlelerle adayı sihirbaza; rapor zaten
* varsa kilitli listenin durduğu /listem paywall'ına iter.
* Tasarım: flat indigo — gradient/gölge yok, CTA funnel turuncusu.
* Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel
* metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin
* durduğu /listem paywall'ına iter.
* Tasarım: güven veren lacivert yüzey, veri kanıtları ve turuncu CTA.
*/
export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
// İlk render'da bir kez karıştırılır; SSR boş metinle başladığı için
// hydration uyuşmazlığı yaratmaz.
const [metinler] = useState(() => karistir(FUNNEL_METINLERI));
const ctaSinif =
"h-10 shrink-0 cursor-pointer bg-orange-500 px-5 font-semibold text-white transition-colors duration-200 hover:bg-orange-600 focus-visible:ring-white/70";
"h-12 w-full shrink-0 cursor-pointer rounded-xl bg-orange-500 px-5 font-semibold text-white shadow-[0_8px_24px_rgba(249,115,22,0.28)] transition-[background-color,transform,box-shadow] duration-200 hover:bg-orange-400 hover:shadow-[0_10px_28px_rgba(249,115,22,0.36)] active:scale-[0.98] focus-visible:ring-white/70 sm:w-auto";
return (
<section
aria-label="Tercih listeni kur"
className="mt-6 flex flex-col gap-4 rounded-2xl bg-indigo-600 p-5 sm:flex-row sm:items-center sm:gap-6"
className="relative mt-6 overflow-hidden rounded-3xl border border-blue-400/20 bg-[#0b1f3a] px-5 py-6 text-white shadow-[0_20px_50px_-28px_rgba(15,71,150,0.65)] sm:px-7 sm:py-7"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-indigo-500"
aria-hidden
>
<Sparkles className="size-5 text-white" />
<div
aria-hidden
className="pointer-events-none absolute -right-16 -top-24 size-64 rounded-full bg-blue-500/25 blur-3xl"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-20 left-1/3 size-48 rounded-full bg-cyan-400/10 blur-3xl"
/>
<div className="relative grid items-center gap-6 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-8">
<div className="min-w-0">
<TypingAnimation
as="h2"
words={metinler}
loop
typeSpeed={38}
deleteSpeed={18}
pauseDelay={2600}
className="min-h-[2.5em] max-w-2xl font-heading text-2xl font-bold leading-tight tracking-[-0.02em] sm:min-h-[1.25em] sm:text-3xl"
/>
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-2">
{KANITLAR.map(({ ikon: Ikon, etiket }) => (
<span
key={etiket}
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-200"
>
<Ikon className="size-3.5 text-cyan-300" aria-hidden />
{etiket}
</span>
))}
</div>
</div>
<TypingAnimation
as="p"
words={CUMLELER}
loop
typeSpeed={45}
deleteSpeed={20}
pauseDelay={2400}
className="min-h-14 min-w-0 flex-1 font-heading text-lg font-bold leading-snug text-white sm:min-h-8 sm:text-2xl"
/>
</div>
{hedefListem ? (
<Button asChild className={ctaSinif}>
<Link href="/listem">
Listeni
{hedefListem ? (
<Button asChild size="lg" className={ctaSinif}>
<Link href="/listem">
Listeni
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
) : (
<Button
size="lg"
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
className={ctaSinif}
>
Listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
) : (
<Button
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
className={ctaSinif}
>
Listemi oluştur
<ArrowRight className="size-4" aria-hidden />
</Button>
)}
</Button>
)}
</div>
</section>
);
}

View File

@@ -47,8 +47,7 @@ function pinleriTuret(
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
const risk =
riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic;
if (kilitli) {
@@ -192,7 +191,7 @@ export function ListePaneli({
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra ·{" "}
{kilitli
? `ilk ${ACIK_SATIR} tercih açık`
? `tüm taban verileri açık · ${ACIK_SATIR} AI analiz örneği`
: `${kredi} kredin kaldı`}
</p>
</div>
@@ -236,29 +235,35 @@ export function ListePaneli({
}}
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
kilitOverlay={
<>
<Lock className="size-8 text-slate-500" aria-hidden />
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900">
24 tercihin {24 - ACIK_SATIR}&apos;i kilidin ardında
</p>
<p className="max-w-sm text-center text-sm text-slate-600">
Her satır için gerekçe, risk notu ve 4 yıllık trend + PDF çıktı
+ 2 revizyon hakkı + 60 kredi seni bekliyor.
</p>
<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 AI 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 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
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 Tüm listeyi </Link>
<Link href="/paket">299 TL Analizleri </Link>
</Button>
{kredi > 0 ? (
<p className="text-xs text-slate-500">
Kalan {kredi} kredin AI danışmana soru sormak için cebinde
duruyor.
</p>
) : null}
</>
</div>
}
/>
</div>
@@ -280,6 +285,7 @@ export function ListePaneli({
function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0);
const govdeRef = useRef<HTMLElement>(null);
useEffect(() => {
const interval = setInterval(() => {
@@ -288,8 +294,19 @@ function UretimGovdesi() {
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 className="mt-16 flex flex-col items-center justify-center gap-4 rounded-2xl border border-slate-200 bg-white py-16 text-center">
<section
ref={govdeRef}
tabIndex={-1}
aria-live="polite"
className="mt-16 flex flex-col items-center justify-center gap-4 rounded-2xl border border-slate-200 bg-white py-16 text-center outline-none"
>
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500">

View File

@@ -124,31 +124,14 @@ export default async function SonucPage({
<span className="font-semibold text-red-600">kırmızı riskli</span>.
</p>
{/* Puan türü seçimi */}
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Puan türü">
{(Object.keys(PUAN_TURLERI) as PuanTuruKey[]).map((key) => (
<Button
key={key}
asChild
size="sm"
variant={key === turKey ? "default" : "outline"}
className="cursor-pointer"
>
<Link href={`/sonuc?sira=${sira}&tur=${key}`}>
{TUR_LABELS[key]}
</Link>
</Button>
))}
</nav>
{/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
varsa /listem paywall'ına) iten banner — paketli kullanıcıya
satacak bir şey kalmadığından gösterilmez */}
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
{/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana
gövdesi; + ile manuel 24'lük liste kurulur */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} />
gövdesi; puan türü seçici de listenin başında yaşar */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
sayfanın en altında yaşar */}

View File

@@ -1,14 +1,11 @@
"use client";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sparkles, X } from "lucide-react";
import { animate } from "motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SihirbazFacetleri } from "@/lib/db";
@@ -19,11 +16,7 @@ import {
} from "@/lib/sihirbaz";
import { SihirbazModal } from "./sihirbaz-modal";
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
import {
ListePaneli,
type MevcutRapor,
type PanelDurum,
} from "./liste-paneli";
import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
@@ -75,6 +68,7 @@ export function SihirbazBolumu({
}) {
const router = useRouter();
const monteEdildi = useMonteEdildi();
const hareketiAzalt = useReducedMotion();
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
@@ -94,6 +88,7 @@ export function SihirbazBolumu({
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
useEffect(() => {
@@ -131,8 +126,61 @@ export function SihirbazBolumu({
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac);
}, []);
/**
* 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;
const modal = document.querySelector<HTMLElement>("[data-sihirbaz-modal]");
if (!modal) return;
const mevcutTransform = getComputedStyle(modal).transform;
const hedefTransform =
mevcutTransform === "none"
? "translate3d(0, 48px, 0)"
: new DOMMatrixReadOnly(mevcutTransform).translate(0, 48).toString();
const perde = document.querySelector<HTMLElement>(
'[data-slot="dialog-overlay"]',
);
const modalAnimasyonu = animate(
modal,
{
opacity: 0,
transform: hedefTransform,
},
{
duration: 0.24,
ease: [0.4, 0, 0.7, 0.2],
},
);
if (perde) {
await Promise.all([
modalAnimasyonu,
animate(perde, { opacity: 0 }, { duration: 0.2, ease: "easeOut" }),
]);
return;
}
await modalAnimasyonu;
}
async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
bolumuGorunureAl();
await modalCikisiniOynat();
setElleAcik(false);
setDurum("uretiliyor");
@@ -167,16 +215,20 @@ export function SihirbazBolumu({
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
setDurum("bos");
setElleAcik(true);
modalaDon();
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
setDurum("bos");
setElleAcik(true);
modalaDon();
}
}
/** Hata sonrası panelden modala dönüş. */
function modalaDon() {
setDurum("bos");
setElleAcik(true);
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
@@ -188,13 +240,14 @@ export function SihirbazBolumu({
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
function ilerle(secimler: SihirbazSecimleri) {
async function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler);
if (!girisliMi) {
await modalCikisiniOynat();
secimleriKaydetVeGirise(secimler);
return;
}
void uret(secimler);
await uret(secimler);
}
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
@@ -227,76 +280,119 @@ export function SihirbazBolumu({
}
const panelde = Boolean(rapor) || durum !== "bos";
const panelAnahtari =
durum !== "bos" ? durum : rapor ? "rapor-hazir" : "sihirbaz-cta";
return (
<>
{/* AI çı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. */}
{durum !== "bos" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
toast.success("Listen yeniden kuruldu.");
} else {
toast.error(sonuc.error);
<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],
},
}
}
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>
) : (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Bu listeyi 24 tercihlik plana çevirelim mi?
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli,
dengeli bir tercih listesi kuralım her satır için yerleşme riski
analiziyle.
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
Listemi oluştur
</Button>
</section>
)}
{durum !== "bos" ? (
<ListePaneli
durum={durum}
rapor={null}
hasPaket={paketli}
kredi={kredi}
sira={sira}
acilis={acilis}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
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>
) : (
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Bu listeyi 24 tercihlik plana çevirelim mi?
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Birkaç adımda ilgi alanların ve şehir tercihine göre
gerekçeli, dengeli bir tercih listesi kuralım her satır için
yerleşme riski analiziyle.
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
Listemi oluştur
</Button>
</section>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
{monteEdildi && !panelde && !kartGizle && !acik ? (
@@ -334,7 +430,9 @@ export function SihirbazBolumu({
</div>
) : null}
{monteEdildi ? (
{/* Ü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" ? (
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}

View File

@@ -32,7 +32,10 @@ export function SihirbazModal({
}) {
return (
<Dialog open={acik} onOpenChange={onAcikDegisti}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl">
<DialogContent
data-sihirbaz-modal
className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl"
>
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
<DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur.