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.

View File

@@ -5,7 +5,12 @@
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
import { cities } from "turkey-map-react/lib/data";
import { normalizeIlAdi } from "@/lib/harita";
import {
HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
export function IlSecimHaritasi({
iller,
@@ -24,7 +29,7 @@ export function IlSecimHaritasi({
return (
<div>
<svg
viewBox="0 0 1050 585"
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="h-auto w-full"
role="group"
aria-label="Okumak istediğin illeri haritadan seç"

View File

@@ -1,8 +1,7 @@
"use client";
// Manuel 24'lük listenin alt çekmecesi (bottom sheet) + "+"dan listeye uçan
// pill katmanı. Layout'ta bir kez monte edilir; navbar'daki Listem butonu
// açar/kapatır.
// Manuel 24'lük listenin alt çekmecesi (bottom sheet). Layout'ta bir kez
// monte edilir; navbar'daki Listem butonu açar/kapatır.
//
// Motion dili (apple-design / emil-design-eng):
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi.
@@ -10,9 +9,12 @@
// - Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
// sağa kaydırmak silme jesti — eşik aşılırsa satır uçar, altından kırmızı
// sil bandı görünür.
// - Uçuş pill'i momentumsuz doğduğu için sekmeden (bounce ~0) hedefe süzülür.
// - prefers-reduced-motion: uçuş yok, sheet cross-fade; silme için X butonu
// her durumda kalır.
// - Silme/temizlemede kart yüksekliği ve kalan satırlar layout projeksiyonuyla
// yumuşakça oturur ("lap diye" kısalma yok). Projeksiyon, sheet'in giriş
// spring'i BİTTİKTEN sonra açılır (oturdu state'i): transform'lu parent
// içinde ölçüm alan satırlar o offset'i kalıcı transform olarak taşıyordu.
// - prefers-reduced-motion: sheet cross-fade; silme için X butonu her
// durumda kalır.
import { useEffect, useState } from "react";
import Link from "next/link";
@@ -39,12 +41,10 @@ import {
cekmeceKapat,
cikar,
temizle,
ucusAbone,
useCekmeceAcik,
useManuelListe,
yenidenSirala,
type ManuelTercih,
type UcusIstegi,
} from "./store";
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
@@ -55,20 +55,27 @@ const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
};
export function ListeCekmecesi() {
const liste = useManuelListe();
const acik = useCekmeceAcik();
return <AnimatePresence>{acik ? <CekmeceSheet /> : null}</AnimatePresence>;
}
function CekmeceSheet() {
const liste = useManuelListe();
const azMotion = useReducedMotion();
const sheetKontrol = useDragControls();
// Giriş spring'i bitmeden satırların layout projeksiyonu kapalı kalır —
// aksi halde transform'lu sheet içinde alınan ölçüm satırları kaydırıyor
const [oturdu, setOturdu] = useState(false);
// Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar
useEffect(() => {
if (!acik) return;
const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") cekmeceKapat();
};
window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle);
}, [acik]);
}, []);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
@@ -77,151 +84,159 @@ export function ListeCekmecesi() {
};
return (
<>
<UcusKatmani />
<AnimatePresence>
{acik ? (
<motion.aside
key="cekmece"
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
y: "calc(100% + 16px)",
transition: {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
},
}
<motion.aside
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={
azMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
y: "calc(100% + 16px)",
transition: {
duration: 0.25,
ease: [0.32, 0.72, 0, 1],
},
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
// layoutRoot: sheet'in kendi y-transform animasyonu, içindeki
// Reorder satırlarının layout ölçümünü bozmasın (satırlar aksi
// halde giriş anındaki offset'i kalıcı transform olarak taşıyor)
layoutRoot
drag={azMotion ? false : "y"}
dragListener={false}
dragControls={sheetKontrol}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={surukleyinceKapat}
aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
>
<div className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<div
onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e);
}}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
}
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
onAnimationComplete={() => setOturdu(true)}
drag={azMotion ? false : "y"}
dragListener={false}
dragControls={sheetKontrol}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={surukleyinceKapat}
aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
>
{/* layout: satır silinince/eklenince kart yüksekliği spring ile oturur.
Doğrudan çocuklar layout="position" taşır ki ölçekleme sırasında
içerik yamulmasın (scale-correction zinciri). */}
<motion.div
layout={oturdu}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl"
>
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<motion.div
layout="position"
onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e);
}}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
>
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</motion.div>
{/* Başlık + doluluk */}
<motion.div
layout="position"
className="border-b border-slate-100 px-5 pb-4 pt-1"
>
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Listem
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
title="Listeyi temizle"
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<button
type="button"
onClick={cekmeceKapat}
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</div>
{/* Başlık + doluluk */}
<div className="border-b border-slate-100 px-5 pb-4 pt-1">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden />
Listem
<span
data-cekmece-hedef
className="font-heading text-sm font-bold tabular-nums text-slate-400"
>
{liste.length}/{MANUEL_LISTE_MAX}
</span>
</h2>
<div className="flex items-center gap-1">
{liste.length > 0 ? (
<button
type="button"
onClick={temizle}
title="Listeyi temizle"
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
>
<Trash2 className="size-4" aria-hidden />
<span className="sr-only">Listeyi temizle</span>
</button>
) : null}
<button
type="button"
onClick={cekmeceKapat}
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</button>
</div>
</div>
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${doluluk}%` }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</div>
<p className="mt-2 text-xs text-slate-500">
Sıralamanla ulaşabildiğin programlardan kendi listen
tutamaktan sürükleyip sırala, sağa kaydırıp sil.
</p>
</div>
{/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center">
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Listen henüz boş
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; 24 tercihlik listen burada birikir.
</p>
</div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layoutScroll
as="ol"
className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-4"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri key={t.id} tercih={t} sira={i + 1} />
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Alt bant: AI danışman köprüsü */}
<div className="border-t border-slate-100 px-5 py-4">
<Link
href="/listem"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı
</Link>
</div>
<X className="size-4" aria-hidden />
<span className="sr-only">Kapat</span>
</button>
</div>
</motion.aside>
) : null}
</AnimatePresence>
</>
</div>
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
role="progressbar"
aria-valuenow={liste.length}
aria-valuemin={0}
aria-valuemax={MANUEL_LISTE_MAX}
aria-label="Liste doluluğu"
>
<motion.div
className="h-full rounded-full bg-primary"
animate={{ width: `${doluluk}%` }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
/>
</div>
<p className="mt-2 text-xs text-slate-500">
Sıralamanla ulaşabildiğin programlardan kendi listen tutamaktan
sürükleyip sırala, sağa kaydırıp sil.
</p>
</motion.div>
{/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? (
<motion.div
layout="position"
className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center"
>
<ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700">
Listen henüz boş
</p>
<p className="text-xs leading-relaxed text-slate-500">
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; 24 tercihlik listen burada birikir.
</p>
</motion.div>
) : (
<Reorder.Group
axis="y"
values={liste}
onReorder={yenidenSirala}
layout="position"
layoutScroll
as="ol"
className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto px-4 py-4"
>
<AnimatePresence initial={false}>
{liste.map((t, i) => (
<CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))}
</AnimatePresence>
</Reorder.Group>
)}
{/* Alt bant: AI danışman köprüsü */}
<motion.div
layout="position"
className="border-t border-slate-100 px-5 py-4"
>
<Link
href="/listem"
onClick={cekmeceKapat}
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
>
<Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı
</Link>
</motion.div>
</motion.div>
</motion.aside>
);
}
@@ -231,9 +246,11 @@ const SIL_ESIK = 90;
function CekmeceSatiri({
tercih,
sira,
projeksiyonAcik,
}: {
tercih: ManuelTercih;
sira: number;
projeksiyonAcik: boolean;
}) {
const stil = tercih.risk ? RISK_STIL[tercih.risk] : null;
const siraKontrol = useDragControls();
@@ -255,8 +272,8 @@ function CekmeceSatiri({
<Reorder.Item
value={tercih}
as="li"
layout="position"
layoutId={`manuel-tercih-${tercih.id}`}
layout={projeksiyonAcik ? "position" : undefined}
layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
dragListener={false}
dragControls={siraKontrol}
initial={{ opacity: 0, y: 8, scale: 0.97 }}
@@ -338,74 +355,3 @@ function CekmeceSatiri({
</Reorder.Item>
);
}
// ---------------------------------------------------------------------------
// Uçuş katmanı: "+" butonundan Listem hedefine süzülen pill
// ---------------------------------------------------------------------------
type Ucus = UcusIstegi & { id: number; hedef: { x: number; y: number } };
let ucusSayac = 0;
/** Hedef nokta: çekmece açıksa içindeki sayaç, değilse navbar'daki buton. */
function hedefBul(): { x: number; y: number } | null {
const el =
document.querySelector("[data-cekmece-hedef]") ??
document.querySelector("[data-listem-hedef]");
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
function UcusKatmani() {
const [ucuslar, setUcuslar] = useState<Ucus[]>([]);
const azMotion = useReducedMotion();
useEffect(() => {
if (azMotion) return; // reduced motion: rozet pulse yeter
return ucusAbone((istek) => {
const hedef = hedefBul();
if (!hedef) return;
setUcuslar((u) => [...u, { ...istek, id: ucusSayac++, hedef }]);
});
}, [azMotion]);
if (ucuslar.length === 0) return null;
return (
<div
className="pointer-events-none fixed inset-0 z-[70] print:hidden"
aria-hidden
>
{ucuslar.map((u) => (
<motion.div
key={u.id}
initial={{ x: u.kaynak.x, y: u.kaynak.y, scale: 1, opacity: 1 }}
animate={{
x: u.hedef.x,
y: u.hedef.y,
scale: 0.3,
opacity: 0.4,
}}
transition={{ type: "spring", duration: 0.55, bounce: 0 }}
onAnimationComplete={() =>
setUcuslar((mevcut) => mevcut.filter((x) => x.id !== u.id))
}
className="absolute left-0 top-0"
>
{/* Merkezleme iç sarmalayıcıda: motion'ın x/y transformu ile çakışmasın */}
<div className="flex max-w-56 -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-slate-200 bg-white py-1.5 pl-3 pr-4 shadow-lg">
{u.tercih.risk ? (
<span
className={`size-2 shrink-0 rounded-full ${RISK_STIL[u.tercih.risk].nokta}`}
/>
) : null}
<span className="truncate text-xs font-semibold text-slate-700">
{u.tercih.isim}
</span>
</div>
</motion.div>
))}
</div>
);
}

View File

@@ -1,8 +1,7 @@
"use client";
// Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca
// çekmeceyi açar/kapatır. Uçuş animasyonunun kapalı-çekmece hedefi de budur
// (data-listem-hedef). Sayaç değişince rozet spring ile "pulse" yapar.
// çekmeceyi açar/kapatır. Sayaç değişince rozet spring ile "pulse" yapar.
import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
@@ -21,7 +20,6 @@ export function ListemButonu() {
return (
<button
type="button"
data-listem-hedef
onClick={cekmeceDegistir}
aria-expanded={acik}
aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`}

View File

@@ -22,13 +22,6 @@ export type ManuelTercih = {
risk: RiskSeviyesi | null;
};
/** "+" butonundan listeye uçan pill'in tek seferlik isteği */
export type UcusIstegi = {
tercih: ManuelTercih;
/** Kaynak butonun viewport koordinatları */
kaynak: { x: number; y: number };
};
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
const BOS_LISTE: ManuelTercih[] = [];
@@ -37,7 +30,6 @@ let cekmeceAcik = false;
let yuklendi = false;
const dinleyiciler = new Set<() => void>();
const ucusDinleyiciler = new Set<(istek: UcusIstegi) => void>();
function yayinla() {
for (const cb of dinleyiciler) cb();
@@ -154,15 +146,3 @@ export function cekmeceDegistir() {
cekmeceAcik = !cekmeceAcik;
yayinla();
}
/** "+" butonu uçuş animasyonu tetikler; katman (liste-cekmecesi) dinler. */
export function ucusBaslat(istek: UcusIstegi) {
for (const cb of ucusDinleyiciler) cb(istek);
}
export function ucusAbone(cb: (istek: UcusIstegi) => void) {
ucusDinleyiciler.add(cb);
return () => {
ucusDinleyiciler.delete(cb);
};
}

View File

@@ -2,12 +2,21 @@
// Sonuç sayfasının AI'sız ham listesi: yalnızca başarı sıralamasıyla
// yokatlas'tan gelen programlar, dilim sekmeleri halinde. Her satırın "+"
// butonu programı manuel 24'lük listeye ekler; pill navbar'daki (ya da açık
// çekmecedeki) Listem hedefine uçar (bkz. manuel-liste/liste-cekmecesi).
// butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
import { useState } from "react";
import { Check, Plus, Rocket, Scale, ShieldCheck } from "lucide-react";
import type { Program, RankResults } from "@/lib/db";
import Link from "next/link";
import { useLinkStatus } from "next/link";
import {
Check,
Plus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { Program, PuanTuruKey, RankResults } from "@/lib/db";
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import {
Table,
@@ -21,7 +30,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
MANUEL_LISTE_MAX,
ekle,
ucusBaslat,
useManuelListe,
type ManuelTercih,
} from "@/components/manuel-liste/store";
@@ -65,6 +73,61 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
riskli: "bg-red-500",
};
// Seçicideki kısa etiketler — sayfa başlığındaki uzun hallerinden bağımsız
const TUR_ETIKETLERI: Record<PuanTuruKey, string> = {
say: "Sayısal",
ea: "Eşit Ağırlık",
soz: "Sözel",
dil: "Dil",
tyt: "TYT",
};
// useLinkStatus Link'in altındaki bir bileşende çağrılmalı; sunucu
// gidiş-dönüşü sürerken tıklanan pill hafifçe nabız atar (layout shift yok).
function SeciciEtiket({ children }: { children: React.ReactNode }) {
const { pending } = useLinkStatus();
return (
<span
className={`transition-opacity duration-150 ${
pending ? "animate-pulse opacity-60" : ""
}`}
>
{children}
</span>
);
}
function PuanTuruSecici({
sira,
aktif,
}: {
sira: number;
aktif: PuanTuruKey;
}) {
return (
<nav
aria-label="Puan türü"
className="flex w-fit max-w-full flex-wrap gap-1 rounded-full border border-slate-200 bg-slate-100 p-1"
>
{(Object.keys(TUR_ETIKETLERI) as PuanTuruKey[]).map((key) => (
<Link
key={key}
href={`/sonuc?sira=${sira}&tur=${key}`}
scroll={false}
aria-current={key === aktif ? "page" : undefined}
className={`cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-[background-color,color,transform] duration-150 active:scale-[0.97] ${
key === aktif
? "bg-white text-slate-900 shadow-sm"
: "text-slate-500 hover:text-slate-900"
}`}
>
<SeciciEtiket>{TUR_ETIKETLERI[key]}</SeciciEtiket>
</Link>
))}
</nav>
);
}
function efektifSira(p: Program): number | null {
return p.sira2025 ?? p.sira2024;
}
@@ -84,40 +147,34 @@ function tercihYap(p: Program, adaySira: number): ManuelTercih {
export function ProgramTablosu({
sonuclar,
adaySira,
tur,
}: {
sonuclar: RankResults;
adaySira: number;
tur: PuanTuruKey;
}) {
const liste = useManuelListe();
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
const listedekiler = new Set(liste.map((t) => t.id));
const doldu = liste.length >= MANUEL_LISTE_MAX;
function eklemeyiDene(p: Program, buton: HTMLElement) {
const tercih = tercihYap(p, adaySira);
if (!ekle(tercih)) return;
const r = buton.getBoundingClientRect();
ucusBaslat({
tercih,
kaynak: { x: r.left + r.width / 2, y: r.top + r.height / 2 },
});
function eklemeyiDene(p: Program) {
ekle(tercihYap(p, adaySira));
}
return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini kendi 24&apos;lük listene at.
</span>
</p>
</div>
<div>
<h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar
</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
sıralamalarına göre ulaşabildiğin bölümler.{" "}
<span className="font-medium text-slate-700">
ile beğendiklerini kendi 24&apos;lük listene at.
</span>
</p>
</div>
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
@@ -130,17 +187,29 @@ export function ProgramTablosu({
onValueChange={(v) => setAktifDilim(v as DilimKey)}
className="mt-5"
>
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar[d.key].length}
</span>
</TabsTrigger>
))}
</TabsList>
{/* Kontrol çubuğu: tabloyu süzen her şey tablonun hemen üstünde —
solda dilim sekmeleri (birincil), sağda puan türü (ikincil filtre).
Dar ekranda alt alta sarar. */}
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
<TabsList>
{DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
{d.etiket}
<span className="text-xs tabular-nums text-slate-400">
{sonuclar[d.key].length}
</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
Puan türü
</span>
{/* scroll={false}: tür değişince sayfa konumu korunur */}
<PuanTuruSecici sira={adaySira} aktif={tur} />
</div>
</div>
{DILIMLER.map((d) => (
<TabsContent key={d.key} value={d.key}>
@@ -156,6 +225,12 @@ export function ProgramTablosu({
<TableRow>
<TableHead className="w-8" />
<TableHead>Program</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
Kontenjan
</TableHead>
<TableHead className="text-right">Taban sıra</TableHead>
<TableHead className="w-16 text-right">
<span className="sr-only">Listeye ekle</span>
@@ -184,6 +259,86 @@ export function ProgramTablosu({
);
}
// Tabanın 20212025 seyri: küçük sıra = daha rekabetçi olduğundan y ekseni
// ters çevrilir; çizgi yukarı gidiyorsa bölüm yıllar içinde zorlaşıyor demektir.
function SiraGecmisi({ p }: { p: Program }) {
const yillar = [
[2021, p.sira2021],
[2022, p.sira2022],
[2023, p.sira2023],
[2024, p.sira2024],
[2025, p.sira2025],
].filter((y): y is [number, number] => y[1] != null);
if (yillar.length < 2) return <span className="text-slate-400"></span>;
const siralar = yillar.map(([, s]) => s);
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const W = 56;
const H = 18;
const PAD = 2;
const noktalar = yillar.map(([yil, s], i) => {
const x = PAD + (i / (yillar.length - 1)) * (W - PAD * 2);
// min sıra (en rekabetçi) üstte dursun
const y =
max === min
? H / 2
: PAD + ((s - min) / (max - min)) * (H - PAD * 2);
return { x, y, yil, s };
});
const son = noktalar[noktalar.length - 1];
const ozet = yillar
.map(([yil, s]) => `${yil}: ~${s.toLocaleString("tr-TR")}.`)
.join("\n");
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="inline-block h-[18px] w-14 text-slate-300"
role="img"
aria-label={`Taban sıra geçmişi. ${ozet.replaceAll("\n", " ")}`}
>
<title>{ozet}</title>
<polyline
points={noktalar.map((n) => `${n.x},${n.y}`).join(" ")}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={son.x} cy={son.y} r="2" className="fill-slate-500" />
</svg>
);
}
// Taban sıra iki yıl üst üste %10'dan fazla oynadıysa yönünü gösterir;
// küçük sıra = daha rekabetçi taban demektir.
function TabanTrendi({ p }: { p: Program }) {
if (p.sira2025 == null || p.sira2024 == null) return null;
const oran = (p.sira2024 - p.sira2025) / p.sira2024;
if (Math.abs(oran) < 0.1) return null;
const yukseliyor = oran > 0;
const gecis = `${p.sira2024.toLocaleString("tr-TR")}${p.sira2025.toLocaleString("tr-TR")}`;
return yukseliyor ? (
<TrendingUp
className="size-3 shrink-0 text-amber-500"
aria-label={`Taban yükseliyor: ${gecis}`}
role="img"
>
<title>{`Taban yükseliyor: ${gecis}`}</title>
</TrendingUp>
) : (
<TrendingDown
className="size-3 shrink-0 text-emerald-600"
aria-label={`Taban geriliyor: ${gecis}`}
role="img"
>
<title>{`Taban geriliyor: ${gecis}`}</title>
</TrendingDown>
);
}
function ProgramSatiri({
program: p,
adaySira,
@@ -195,10 +350,11 @@ function ProgramSatiri({
adaySira: number;
listede: boolean;
doldu: boolean;
onEkle: (p: Program, buton: HTMLElement) => void;
onEkle: (p: Program) => void;
}) {
const taban = efektifSira(p);
const risk = riskHesapla(taban, adaySira);
const devlet = p.unitur === "DEVLET";
return (
<TableRow>
@@ -212,19 +368,60 @@ function ProgramSatiri({
</TableCell>
<TableCell className="max-w-0 w-full">
<span className="block truncate text-sm font-semibold">{p.isim}</span>
<span className="block truncate text-xs text-slate-500">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<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>
<span className="truncate">
{p.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">· {p.sure} yıl</span>
) : null}
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<SiraGecmisi p={p} />
</TableCell>
<TableCell
className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell"
title={
p.kontenjan2025 != null && p.yerlesen2025 != null
? `${p.kontenjan2025} kontenjan, ${p.yerlesen2025} yerleşen`
: undefined
}
>
{p.kontenjan2025 != null ? (
<span className="inline-flex items-center gap-1.5">
{p.yerlesen2025 != null && p.yerlesen2025 < p.kontenjan2025 ? (
<span className="rounded bg-amber-50 px-1 py-px text-[10px] font-medium leading-4 text-amber-600">
boş kaldı
</span>
) : null}
{p.kontenjan2025.toLocaleString("tr-TR")} kişi
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
<span className="inline-flex items-center gap-1">
<TabanTrendi p={p} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-4 text-right">
<button
type="button"
disabled={listede || doldu}
onClick={(e) => onEkle(p, e.currentTarget)}
onClick={() => onEkle(p)}
title={
listede
? "Zaten listende"

View File

@@ -1,18 +1,13 @@
"use client";
// 24'lük listenin gövdesi — metin diyeti sonrası hali:
// AI cümleleri satırda değil, satıra dokununca açılan detayda yaşar.
// Satır tek bakışta taranır: sıra, program, üniversite·il, taban, trend, risk.
// Strateji şeridi paragraf yerine dilim sayıları + kısa uyarı rozetleri basar.
//
// kilitliBaslangic verilirse o indeksten sonraki satırlar artan blur ile
// kilitlenir ve üzerine kilitOverlay bindirilir (paketsiz önizleme).
// Kilitli satırların içeriği sunucuda zaten maskelidir (bkz. rapor-maske);
// buradaki blur yalnızca görsel dil.
// 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
// herkese görünür. AI gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
// ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
ChevronDown,
Lock,
MapPin,
Minus,
Rocket,
@@ -85,7 +80,7 @@ const TREND_GORUNUM: Record<
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
};
/** Son 4-5 yılın taban sıralaması mini çizgisi (detay panelinde). */
/** Son 5 yılın taban sıralaması mini çizgisi (detay panelinde). */
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
(n): n is { yil: number; sira: number } => n.sira != null,
@@ -114,7 +109,7 @@ function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
<div className="mt-2 flex items-center gap-2">
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-10 w-[150px]"
className="h-10 w-37.5"
role="img"
aria-label={`Taban sıralaması ${noktalar[0].yil}: ${noktalar[0].sira.toLocaleString("tr-TR")}, ${noktalar[noktalar.length - 1].yil}: ${noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}`}
>
@@ -177,22 +172,22 @@ export function RaporListesi({
}
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const acikTercihler = rapor.tercihler.slice(0, kilitIdx);
// Kilitli alan: tamamını basmaya gerek yok, blur duvarı kısa tutulur
const kilitliTercihler = rapor.tercihler.slice(kilitIdx, kilitIdx + 5);
const satir = (
t: RaporSonuc["tercihler"][number],
secenekler?: { blurPx?: number },
index: number,
) => {
const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi);
const trendG = trend ? TREND_GORUNUM[trend] : null;
const acikMi = acik.has(t.sira) && !secenekler?.blurPx;
const acikMi = acik.has(t.sira);
const sira2024 = p?.siraGecmisi?.[3] ?? null;
const sira2025 = p?.siraGecmisi?.[4] ?? null;
return (
<li
@@ -201,23 +196,20 @@ export function RaporListesi({
if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira);
}}
style={
secenekler?.blurPx
? { filter: `blur(${secenekler.blurPx}px)` }
: undefined
}
className={`rounded-xl border border-l-4 border-slate-200 bg-white ${stil.kenar}`}
className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-200 hover:border-slate-300 hover:shadow-xs ${
analizKilitli ? "border-slate-200" : `border-l-4 ${stil.kenar}`
}`}
>
<button
type="button"
onClick={() => tiklandi(t.sira)}
aria-expanded={acikMi}
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left"
className="grid min-h-16 w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 px-3 py-2.5 text-left xl:grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] xl:px-4"
>
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira}
</span>
<span className="min-w-0 flex-1">
<span className="min-w-0">
<span className="block truncate text-sm font-semibold">
{p?.isim ?? t.programId}
</span>
@@ -225,22 +217,47 @@ export function RaporListesi({
{p?.universite}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span>
<span className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-slate-500 xl:hidden">
<span>2024: {sira2024?.toLocaleString("tr-TR") ?? "—"}</span>
<span>2025: {sira2025?.toLocaleString("tr-TR") ?? "—"}</span>
{trendG ? <span className={trendG.renk}>{trendG.etiket}</span> : null}
</span>
</span>
<span className="hidden shrink-0 text-xs tabular-nums text-slate-500 sm:block">
{p?.efektifSira != null
? `~${p.efektifSira.toLocaleString("tr-TR")}.`
: ""}
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
{sira2024?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
{sira2025?.toLocaleString("tr-TR") ?? "—"}
</span>
<span className="hidden items-center gap-1.5 text-xs xl:flex">
{trendG ? (
<>
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
<span className={trendG.renk}>
{trend === "zorlasiyor"
? "Zorlaşıyor"
: trend === "kolaylasiyor"
? "Rahatlıyor"
: "Stabil"}
</span>
</>
) : (
<span className="text-slate-400">Veri yok</span>
)}
</span>
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
{analizKilitli ? (
<>
<Lock className="size-3.5 text-slate-400" aria-hidden />
<span className="text-slate-500">Pakette</span>
</>
) : (
<>
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
<span>{RISK_ETIKET[risk]}</span>
</>
)}
</span>
{trendG ? (
<trendG.icon
className={`size-4 shrink-0 ${trendG.renk}`}
aria-label={trendG.etiket}
/>
) : null}
<span
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
aria-label={`${DILIM_ETIKET[t.dilim]} · ${RISK_ETIKET[risk]}`}
/>
<ChevronDown
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden
@@ -248,43 +265,65 @@ export function RaporListesi({
</button>
{acikMi ? (
<div className="border-t border-slate-100 px-4 py-3">
<div className="border-t border-slate-100 bg-slate-50/50 px-4 py-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</span>
{analizKilitli ? (
<>
<Lock className="size-3.5" aria-hidden />
<span className="font-medium">Kişisel analiz pakette</span>
</>
) : (
<>
<stil.icon className="size-3.5" aria-hidden />
<span className="font-medium">
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</span>
</>
)}
{p?.unitur ? <span>· {p.unitur}</span> : null}
{p?.efektifSira != null ? (
<span className="sm:hidden">
· taban ~{p.efektifSira.toLocaleString("tr-TR")}.
</span>
) : null}
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-lg bg-slate-50 p-3">
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk notu
</dt>
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
4 yıllık trend
20212025 taban trendi
</dt>
<dd className="mt-1 text-slate-700">
{t.trendOzeti}
{p?.siraGecmisi ? (
<TrendCizgisi gecmis={p.siraGecmisi} />
) : null}
) : (
"Yeterli veri yok."
)}
</dd>
</div>
{analizKilitli ? (
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
<Lock className="size-3.5" aria-hidden />
AI karar desteği
</dt>
<dd className="mt-1 text-sm text-slate-600">
Bu bölümün neden listende olduğunu ve sıralamana özel yerleşme
riskini paketle gör.
</dd>
</div>
) : (
<>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk ve AI yorumu
</dt>
<dd className="mt-1 text-slate-700">
{t.riskNotu} {t.trendOzeti}
</dd>
</div>
</>
)}
</dl>
{onHaritadaGor && p?.il ? (
<button
@@ -304,27 +343,42 @@ export function RaporListesi({
return (
<div>
<p className="text-xs text-slate-500">
Satıra dokun; gerekçe, risk notu ve 4 yıllık trend ılsın.
</p>
<ol className="mt-2 space-y-2">{acikTercihler.map((t) => satir(t))}</ol>
{kilitliTercihler.length > 0 ? (
<div className="relative mt-2">
<ol
className="pointer-events-none max-h-[24rem] select-none space-y-2 overflow-hidden"
aria-hidden
>
{kilitliTercihler.map((t, j) =>
satir(t, { blurPx: Math.min(3 + j * 2, 12) }),
)}
</ol>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-slate-50/20 via-slate-50/75 to-slate-50 px-4">
{kilitOverlay}
</div>
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
<p className="mt-1 text-xs text-slate-500">
Satıra dokunarak 20212025 trendini inceleyebilirsin.
</p>
</div>
) : null}
{kilitIdx < rapor.tercihler.length ? (
<p className="text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null}
</div>
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
<span className="w-7">#</span>
<span>Program</span>
<span className="text-right">2024 taban</span>
<span className="text-right">2025 taban</span>
<span>Ham trend</span>
<span>AI risk</span>
<span className="w-4" />
</div>
<ol className="mt-2 flex flex-col gap-2">
{rapor.tercihler.map((t, index) => (
<Fragment key={t.sira}>
{index === kilitIdx && kilitOverlay ? (
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
{kilitOverlay}
</li>
) : null}
{satir(t, index)}
</Fragment>
))}
</ol>
</div>
);
}

View File

@@ -19,6 +19,7 @@ import { cities } from "turkey-map-react/lib/data";
import { Button } from "@/components/ui/button";
import {
HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
@@ -45,13 +46,11 @@ const RISK_RENK: Record<RiskSeviyesi, string> = {
riskli: "oklch(0.637 0.237 25.3)", // red-500
};
// SVG uzayı 0 0 1050 585 ama ülke çizimleri y≈144'te başlıyor; üstteki ölü
// bandı kırpıyoruz ki harita sayfada gereksiz boşluk bırakmasın.
const TAM_GORUNUM = {
x: 0,
y: 138,
y: HARITA_UST_KIRPMA,
w: HARITA_GENISLIK,
h: HARITA_YUKSEKLIK - 138,
h: HARITA_YUKSEKLIK - HARITA_UST_KIRPMA,
};
type ViewBox = typeof TAM_GORUNUM;

View File

@@ -9,6 +9,8 @@ import { UNI_KOORDINAT, IL_MERKEZ, type LatLng } from "./uni-koordinatlar";
export const HARITA_GENISLIK = 1050;
export const HARITA_YUKSEKLIK = 585;
/** Ülke çizimi y≈144'te başlıyor; üstteki ölü bant her haritada kırpılır. */
export const HARITA_UST_KIRPMA = 138;
export function latLngToXy([lat, lng]: LatLng): { x: number; y: number } {
return {

View File

@@ -1,31 +1,18 @@
// Paketsiz kullanıcıya giden raporun sunucu tarafı maskesi.
// Blur yalnızca CSS olduğundan kilitli satırların gerçek içeriği client'a
// hiç inmemeli: ilk ACIK_SATIR tercih gerçek kalır, gerisinin metinleri
// makul görünen placeholder'larla değiştirilir. Risk rengini besleyen
// dilim + il gerçek kalır ki blur altındaki dağılım adayı yansıtsın;
// taban sıralaması (efektifSira) sızdırılmaz.
// YÖK Atlas'ın ham program ve taban sıralaması verileri ücretsiz kalır.
// Yalnızca AI'ın ürettiği kişisel gerekçe/risk/trend yorumu maskelenir;
// böylece istemciye ücretli analiz metni hiç inmez.
import type { RaporSonuc } from "./ai/rapor";
export const ACIK_SATIR = 3;
const SAHTE_ISIMLER = [
"Bilgisayar Mühendisliği (İngilizce)",
"Elektrik-Elektronik Mühendisliği",
"Endüstri Mühendisliği",
"Yazılım Mühendisliği (Burslu)",
"Makine Mühendisliği",
"İşletme (İngilizce)",
"Psikoloji",
"Mimarlık",
];
const SAHTE_GEREKCE =
"Sıralamana ve seçtiğin önceliklere göre dengeli bir aday; ayrıntılı gerekçe kilidin ardında.";
"Bu tercih için kişisel gerekçe paketle açılır.";
const SAHTE_RISK =
"Yerleşme ihtimali değerlendirmesi paketi aktive edince görünür.";
"Sıralamana özel yerleşme riski paketle açılır.";
const SAHTE_TREND =
"Son 4 yılın taban sıralaması hareketi paketi aktive edince görünür.";
"Ham trend verisi ücretsizdir; AI yorumu paketle açılır.";
/** genelDegerlendirme'nin yalnızca ilk 1-2 cümlesini bırakır. */
function degerlendirmeKirp(metin: string): string {
@@ -46,28 +33,11 @@ export function raporMaskele(rapor: RaporSonuc): RaporSonuc {
},
);
const programlar: RaporSonuc["programlar"] = {};
rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId];
if (!p) return;
programlar[t.programId] =
i < ACIK_SATIR
? p
: {
isim: SAHTE_ISIMLER[i % SAHTE_ISIMLER.length],
universite: "•••••••• Üniversitesi",
il: p.il,
unitur: p.unitur,
efektifSira: null,
};
});
return {
tercihler,
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme),
// Uyarılar liste geneline dair, satır sırrı taşımaz: paketsizde de kalır.
// Satır bazlı siraGecmisi ise kilitli satırlarda hiç kopyalanmaz (üstte).
uyarilar: rapor.uyarilar,
programlar,
// Program adı, üniversite ve 20212025 taban sıralamaları ham veridir.
programlar: rapor.programlar,
};
}