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

Binary file not shown.

View File

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

View File

@@ -1,54 +1,124 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; 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 { Button } from "@/components/ui/button";
import { TypingAnimation } from "@/components/ui/typing-animation"; import { TypingAnimation } from "@/components/ui/typing-animation";
/** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */ /** Banner CTA'sının sihirbaz modalını açmak için yayınladığı olay. */
export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac"; export const SIHIRBAZ_AC_EVENT = "kolaytercih:sihirbaz-ac";
const CUMLELER = [ const FUNNEL_METINLERI = [
"Seçmekte zorlanıyor musunuz?", "Sıralamana en uygun listeyi birlikte kuralım.",
"Hangi bölüm size uygun, emin değil misiniz?", "Geleceğin 24 satırlık bir plana sığar.",
"Yapay zekâ 24 tercihlik listenizi kursun.", "Gerçek YÖK Atlas verisiyle güvenle seç.",
"Her satırda gerekçe ve yerleşme riski analizi.", "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'ı. * 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 * Rastgele sırayla yazılıp silinen samimi ve profesyonel funnel
* varsa kilitli listenin durduğu /listem paywall'ına iter. * metinleriyle adayı sihirbaza; rapor zaten varsa kilitli listenin
* Tasarım: flat indigo — gradient/gölge yok, CTA funnel turuncusu. * 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 }) { 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 = 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 ( return (
<section <section
aria-label="Tercih listeni kur" 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 <div
className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-indigo-500"
aria-hidden aria-hidden
> className="pointer-events-none absolute -right-16 -top-24 size-64 rounded-full bg-blue-500/25 blur-3xl"
<Sparkles className="size-5 text-white" />
</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
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>
</div>
{hedefListem ? ( {hedefListem ? (
<Button asChild className={ctaSinif}> <Button asChild size="lg" className={ctaSinif}>
<Link href="/listem"> <Link href="/listem">
Listeni Listeni
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
@@ -56,6 +126,7 @@ export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
</Button> </Button>
) : ( ) : (
<Button <Button
size="lg"
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))} onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
className={ctaSinif} className={ctaSinif}
> >
@@ -63,6 +134,7 @@ export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Button> </Button>
)} )}
</div>
</section> </section>
); );
} }

View File

@@ -47,8 +47,7 @@ function pinleriTuret(
rapor.tercihler.forEach((t, i) => { rapor.tercihler.forEach((t, i) => {
const p = rapor.programlar[t.programId]; const p = rapor.programlar[t.programId];
if (!p) return; if (!p) return;
const risk = const risk = riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
riskHesapla(p.efektifSira, adaySira) ?? dilimdenRisk(t.dilim);
const kilitli = i >= kilitliBaslangic; const kilitli = i >= kilitliBaslangic;
if (kilitli) { if (kilitli) {
@@ -192,7 +191,7 @@ export function ListePaneli({
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra ·{" "} {sira.toLocaleString("tr-TR")}. sıra ·{" "}
{kilitli {kilitli
? `ilk ${ACIK_SATIR} tercih açık` ? `tüm taban verileri açık · ${ACIK_SATIR} AI analiz örneği`
: `${kredi} kredin kaldı`} : `${kredi} kredin kaldı`}
</p> </p>
</div> </div>
@@ -236,29 +235,35 @@ export function ListePaneli({
}} }}
kilitliBaslangic={kilitli ? ACIK_SATIR : undefined} kilitliBaslangic={kilitli ? ACIK_SATIR : undefined}
kilitOverlay={ kilitOverlay={
<> <div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<Lock className="size-8 text-slate-500" aria-hidden /> <div className="flex gap-3">
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900"> <Lock
24 tercihin {24 - ACIK_SATIR}&apos;i kilidin ardında 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>
<p className="max-w-sm text-center text-sm text-slate-600"> <p className="mt-1 max-w-xl text-sm text-slate-600">
Her satır için gerekçe, risk notu ve 4 yıllık trend + PDF çıktı Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
+ 2 revizyon hakkı + 60 kredi seni bekliyor. gerekçe, PDF, 2 revizyon ve 60 AI danışman sorusunu açar.
</p> </p>
{kredi > 0 ? (
<p className="mt-1 text-xs text-slate-500">
Kalan {kredi} deneme kredin hesabında duruyor.
</p>
) : null}
</div>
</div>
<Button <Button
asChild asChild
size="lg" 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> </Button>
{kredi > 0 ? ( </div>
<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() { function UretimGovdesi() {
const [mesajIdx, setMesajIdx] = useState(0); const [mesajIdx, setMesajIdx] = useState(0);
const govdeRef = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -288,8 +294,19 @@ function UretimGovdesi() {
return () => clearInterval(interval); 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 ( 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 /> <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="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500"> <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>. <span className="font-semibold text-red-600">kırmızı riskli</span>.
</p> </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 {/* Satın alım funnel'ı: değişen cümlelerle adayı sihirbaza (rapor
varsa /listem paywall'ına) iten banner — paketli kullanıcıya varsa /listem paywall'ına) iten banner — paketli kullanıcıya
satacak bir şey kalmadığından gösterilmez */} satacak bir şey kalmadığından gösterilmez */}
{!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null} {!hasPaket ? <FunnelBanner hedefListem={Boolean(mevcutRapor)} /> : null}
{/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana {/* AI'dan bağımsız ham tablo + manuel liste haritası — sayfanın ana
gövdesi; + ile manuel 24'lük liste kurulur */} gövdesi; puan türü seçici de listenin başında yaşar */}
<ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} /> <ProgramTablosu sonuclar={hamSonuclar} adaySira={sira} tur={turKey} />
{/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli {/* Sihirbaz akışı (CTA + modal + AI listesi) — blurlu AI paneli
sayfanın en altında yaşar */} sayfanın en altında yaşar */}

View File

@@ -1,14 +1,11 @@
"use client"; "use client";
import { import { useEffect, useRef, useState, useSyncExternalStore } from "react";
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Sparkles, X } from "lucide-react"; import { Sparkles, X } from "lucide-react";
import { animate } from "motion";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/lib/db";
@@ -19,11 +16,7 @@ import {
} from "@/lib/sihirbaz"; } from "@/lib/sihirbaz";
import { SihirbazModal } from "./sihirbaz-modal"; import { SihirbazModal } from "./sihirbaz-modal";
import { SIHIRBAZ_AC_EVENT } from "./funnel-banner"; import { SIHIRBAZ_AC_EVENT } from "./funnel-banner";
import { import { ListePaneli, type MevcutRapor, type PanelDurum } from "./liste-paneli";
ListePaneli,
type MevcutRapor,
type PanelDurum,
} from "./liste-paneli";
import { listeOlustur, listeRevize } from "./actions"; import { listeOlustur, listeRevize } from "./actions";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali"; const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
@@ -75,6 +68,7 @@ export function SihirbazBolumu({
}) { }) {
const router = useRouter(); const router = useRouter();
const monteEdildi = useMonteEdildi(); const monteEdildi = useMonteEdildi();
const hareketiAzalt = useReducedMotion();
const [elleAcik, setElleAcik] = useState(false); const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false); const [elleKapandi, setElleKapandi] = useState(false);
@@ -94,6 +88,7 @@ export function SihirbazBolumu({
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz) // Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
const requestIdRef = useRef<string | null>(null); const requestIdRef = useRef<string | null>(null);
const otomatikBasladi = useRef(false); const otomatikBasladi = useRef(false);
const bolumRef = useRef<HTMLDivElement>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku // Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
useEffect(() => { useEffect(() => {
@@ -131,8 +126,61 @@ export function SihirbazBolumu({
return () => window.removeEventListener(SIHIRBAZ_AC_EVENT, ac); 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) { async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
bolumuGorunureAl();
await modalCikisiniOynat();
setElleAcik(false); setElleAcik(false);
setDurum("uretiliyor"); setDurum("uretiliyor");
@@ -167,15 +215,19 @@ export function SihirbazBolumu({
secimleriKaydetVeGirise(secimler); secimleriKaydetVeGirise(secimler);
} else { } else {
toast.error(sonuc.error); toast.error(sonuc.error);
setDurum("bos"); modalaDon();
setElleAcik(true);
} }
} catch { } catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?"); toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
modalaDon();
}
}
/** Hata sonrası panelden modala dönüş. */
function modalaDon() {
setDurum("bos"); setDurum("bos");
setElleAcik(true); setElleAcik(true);
} }
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) { function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try { try {
@@ -188,13 +240,14 @@ export function SihirbazBolumu({
router.push(`/giris?callback=${encodeURIComponent(geri)}`); router.push(`/giris?callback=${encodeURIComponent(geri)}`);
} }
function ilerle(secimler: SihirbazSecimleri) { async function ilerle(secimler: SihirbazSecimleri) {
setSonSecimler(secimler); setSonSecimler(secimler);
if (!girisliMi) { if (!girisliMi) {
await modalCikisiniOynat();
secimleriKaydetVeGirise(secimler); secimleriKaydetVeGirise(secimler);
return; return;
} }
void uret(secimler); await uret(secimler);
} }
// Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla // Giriş dönüşü: storage'daki eşleşen seçimlerle otomatik üretime başla
@@ -227,12 +280,52 @@ export function SihirbazBolumu({
} }
const panelde = Boolean(rapor) || durum !== "bos"; const panelde = Boolean(rapor) || durum !== "bos";
const panelAnahtari =
durum !== "bos" ? durum : rapor ? "rapor-hazir" : "sihirbaz-cta";
return ( return (
<> <>
{/* AI çıktısı (rapor listesi/haritası) bu sayfada render edilmez; {/* AI çıktısı (rapor listesi/haritası) bu sayfada render edilmez;
panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel panel yalnızca üretim ve kredi-yetersiz gibi geçici funnel
durumlarını gösterir, listenin kendisi /listem'de yaşar. */} durumlarını gösterir, listenin kendisi /listem'de yaşar. */}
<div ref={bolumRef} className="relative">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={panelAnahtari}
initial={
hareketiAzalt
? { opacity: 0 }
: {
opacity: 0,
transform: "translate3d(0, 8px, 0)",
}
}
animate={{
opacity: 1,
transform: "translate3d(0, 0, 0)",
transition: hareketiAzalt
? { duration: 0.12, ease: "easeOut" }
: {
duration: 0.28,
ease: [0.23, 1, 0.32, 1],
},
}}
exit={
hareketiAzalt
? {
opacity: 0,
transition: { duration: 0.08, ease: "easeOut" },
}
: {
opacity: 0,
transform: "translate3d(0, -4px, 0)",
transition: {
duration: 0.14,
ease: [0.4, 0, 0.7, 0.2],
},
}
}
>
{durum !== "bos" ? ( {durum !== "bos" ? (
<ListePaneli <ListePaneli
durum={durum} durum={durum}
@@ -266,8 +359,8 @@ export function SihirbazBolumu({
24 tercihlik listen hazır 24 tercihlik listen hazır
</h2> </h2>
<p className="mx-auto mt-3 max-w-xl text-slate-600"> <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 Yapay zekânın kurduğu risk analizli listen Listem sayfasında
bekliyor. seni bekliyor.
</p> </p>
<Button <Button
asChild asChild
@@ -283,9 +376,9 @@ export function SihirbazBolumu({
Bu listeyi 24 tercihlik plana çevirelim mi? Bu listeyi 24 tercihlik plana çevirelim mi?
</h2> </h2>
<p className="mx-auto mt-3 max-w-xl text-white/90"> <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, Birkaç adımda ilgi alanların ve şehir tercihine göre
dengeli bir tercih listesi kuralım her satır için yerleşme riski gerekçeli, dengeli bir tercih listesi kuralım her satır için
analiziyle. yerleşme riski analiziyle.
</p> </p>
<Button <Button
size="lg" size="lg"
@@ -297,6 +390,9 @@ export function SihirbazBolumu({
</Button> </Button>
</section> </section>
)} )}
</motion.div>
</AnimatePresence>
</div>
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */} {/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
{monteEdildi && !panelde && !kartGizle && !acik ? ( {monteEdildi && !panelde && !kartGizle && !acik ? (
@@ -334,7 +430,9 @@ export function SihirbazBolumu({
</div> </div>
) : null} ) : 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 <SihirbazModal
acik={acik} acik={acik}
onAcikDegisti={acikDegisti} onAcikDegisti={acikDegisti}

View File

@@ -32,7 +32,10 @@ export function SihirbazModal({
}) { }) {
return ( return (
<Dialog open={acik} onOpenChange={onAcikDegisti}> <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> <DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur. 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. // kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
import { cities } from "turkey-map-react/lib/data"; 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({ export function IlSecimHaritasi({
iller, iller,
@@ -24,7 +29,7 @@ export function IlSecimHaritasi({
return ( return (
<div> <div>
<svg <svg
viewBox="0 0 1050 585" viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="h-auto w-full" className="h-auto w-full"
role="group" role="group"
aria-label="Okumak istediğin illeri haritadan seç" aria-label="Okumak istediğin illeri haritadan seç"

View File

@@ -1,8 +1,7 @@
"use client"; "use client";
// Manuel 24'lük listenin alt çekmecesi (bottom sheet) + "+"dan listeye uçan // Manuel 24'lük listenin alt çekmecesi (bottom sheet). Layout'ta bir kez
// pill katmanı. Layout'ta bir kez monte edilir; navbar'daki Listem butonu // monte edilir; navbar'daki Listem butonu açar/kapatır.
// açar/kapatır.
// //
// Motion dili (apple-design / emil-design-eng): // Motion dili (apple-design / emil-design-eng):
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi. // - 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 // - 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ı // 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. // sil bandı görünür.
// - Uçuş pill'i momentumsuz doğduğu için sekmeden (bounce ~0) hedefe süzülür. // - Silme/temizlemede kart yüksekliği ve kalan satırlar layout projeksiyonuyla
// - prefers-reduced-motion: uçuş yok, sheet cross-fade; silme için X butonu // yumuşakça oturur ("lap diye" kısalma yok). Projeksiyon, sheet'in giriş
// her durumda kalır. // 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 { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
@@ -39,12 +41,10 @@ import {
cekmeceKapat, cekmeceKapat,
cikar, cikar,
temizle, temizle,
ucusAbone,
useCekmeceAcik, useCekmeceAcik,
useManuelListe, useManuelListe,
yenidenSirala, yenidenSirala,
type ManuelTercih, type ManuelTercih,
type UcusIstegi,
} from "./store"; } from "./store";
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi) // 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() { export function ListeCekmecesi() {
const liste = useManuelListe();
const acik = useCekmeceAcik(); const acik = useCekmeceAcik();
return <AnimatePresence>{acik ? <CekmeceSheet /> : null}</AnimatePresence>;
}
function CekmeceSheet() {
const liste = useManuelListe();
const azMotion = useReducedMotion(); const azMotion = useReducedMotion();
const sheetKontrol = useDragControls(); 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 // Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar
useEffect(() => { useEffect(() => {
if (!acik) return;
const dinle = (e: KeyboardEvent) => { const dinle = (e: KeyboardEvent) => {
if (e.key === "Escape") cekmeceKapat(); if (e.key === "Escape") cekmeceKapat();
}; };
window.addEventListener("keydown", dinle); window.addEventListener("keydown", dinle);
return () => window.removeEventListener("keydown", dinle); return () => window.removeEventListener("keydown", dinle);
}, [acik]); }, []);
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100); const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
@@ -77,13 +84,7 @@ export function ListeCekmecesi() {
}; };
return ( return (
<>
<UcusKatmani />
<AnimatePresence>
{acik ? (
<motion.aside <motion.aside
key="cekmece"
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }} initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
animate={azMotion ? { opacity: 1 } : { y: 0 }} animate={azMotion ? { opacity: 1 } : { y: 0 }}
exit={ exit={
@@ -98,10 +99,7 @@ export function ListeCekmecesi() {
} }
} }
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }} transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
// layoutRoot: sheet'in kendi y-transform animasyonu, içindeki onAnimationComplete={() => setOturdu(true)}
// 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"} drag={azMotion ? false : "y"}
dragListener={false} dragListener={false}
dragControls={sheetKontrol} dragControls={sheetKontrol}
@@ -111,27 +109,35 @@ export function ListeCekmecesi() {
aria-label="Manuel tercih listem" aria-label="Manuel tercih listem"
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3" 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"> {/* 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 */} {/* Tutamak: buradan aşağı çekilince sheet kapanır */}
<div <motion.div
layout="position"
onPointerDown={(e) => { onPointerDown={(e) => {
if (!azMotion) sheetKontrol.start(e); if (!azMotion) sheetKontrol.start(e);
}} }}
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing" 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 /> <div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
</div> </motion.div>
{/* Başlık + doluluk */} {/* Başlık + doluluk */}
<div className="border-b border-slate-100 px-5 pb-4 pt-1"> <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"> <div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-heading text-lg font-bold"> <h2 className="flex items-center gap-2 font-heading text-lg font-bold">
<ListChecks className="size-5 text-primary" aria-hidden /> <ListChecks className="size-5 text-primary" aria-hidden />
Listem Listem
<span <span className="font-heading text-sm font-bold tabular-nums text-slate-400">
data-cekmece-hedef
className="font-heading text-sm font-bold tabular-nums text-slate-400"
>
{liste.length}/{MANUEL_LISTE_MAX} {liste.length}/{MANUEL_LISTE_MAX}
</span> </span>
</h2> </h2>
@@ -172,14 +178,17 @@ export function ListeCekmecesi() {
/> />
</div> </div>
<p className="mt-2 text-xs text-slate-500"> <p className="mt-2 text-xs text-slate-500">
Sıralamanla ulaşabildiğin programlardan kendi listen Sıralamanla ulaşabildiğin programlardan kendi listen tutamaktan
tutamaktan sürükleyip sırala, sağa kaydırıp sil. sürükleyip sırala, sağa kaydırıp sil.
</p> </p>
</div> </motion.div>
{/* Gövde: sürüklenebilir satırlar */} {/* Gövde: sürüklenebilir satırlar */}
{liste.length === 0 ? ( {liste.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center"> <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 /> <ListChecks className="size-8 text-slate-300" aria-hidden />
<p className="font-heading text-sm font-bold text-slate-700"> <p className="font-heading text-sm font-bold text-slate-700">
Listen henüz boş Listen henüz boş
@@ -188,26 +197,35 @@ export function ListeCekmecesi() {
Sonuç sayfasındaki tablodan <span aria-hidden></span> ile Sonuç sayfasındaki tablodan <span aria-hidden></span> ile
program ekle; 24 tercihlik listen burada birikir. program ekle; 24 tercihlik listen burada birikir.
</p> </p>
</div> </motion.div>
) : ( ) : (
<Reorder.Group <Reorder.Group
axis="y" axis="y"
values={liste} values={liste}
onReorder={yenidenSirala} onReorder={yenidenSirala}
layout="position"
layoutScroll layoutScroll
as="ol" as="ol"
className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-4" className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto px-4 py-4"
> >
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{liste.map((t, i) => ( {liste.map((t, i) => (
<CekmeceSatiri key={t.id} tercih={t} sira={i + 1} /> <CekmeceSatiri
key={t.id}
tercih={t}
sira={i + 1}
projeksiyonAcik={oturdu}
/>
))} ))}
</AnimatePresence> </AnimatePresence>
</Reorder.Group> </Reorder.Group>
)} )}
{/* Alt bant: AI danışman köprüsü */} {/* Alt bant: AI danışman köprüsü */}
<div className="border-t border-slate-100 px-5 py-4"> <motion.div
layout="position"
className="border-t border-slate-100 px-5 py-4"
>
<Link <Link
href="/listem" href="/listem"
onClick={cekmeceKapat} onClick={cekmeceKapat}
@@ -216,12 +234,9 @@ export function ListeCekmecesi() {
<Sparkles className="size-4" aria-hidden /> <Sparkles className="size-4" aria-hidden />
AI listeni ve danışmanı AI listeni ve danışmanı
</Link> </Link>
</div> </motion.div>
</div> </motion.div>
</motion.aside> </motion.aside>
) : null}
</AnimatePresence>
</>
); );
} }
@@ -231,9 +246,11 @@ const SIL_ESIK = 90;
function CekmeceSatiri({ function CekmeceSatiri({
tercih, tercih,
sira, sira,
projeksiyonAcik,
}: { }: {
tercih: ManuelTercih; tercih: ManuelTercih;
sira: number; sira: number;
projeksiyonAcik: boolean;
}) { }) {
const stil = tercih.risk ? RISK_STIL[tercih.risk] : null; const stil = tercih.risk ? RISK_STIL[tercih.risk] : null;
const siraKontrol = useDragControls(); const siraKontrol = useDragControls();
@@ -255,8 +272,8 @@ function CekmeceSatiri({
<Reorder.Item <Reorder.Item
value={tercih} value={tercih}
as="li" as="li"
layout="position" layout={projeksiyonAcik ? "position" : undefined}
layoutId={`manuel-tercih-${tercih.id}`} layoutId={projeksiyonAcik ? `manuel-tercih-${tercih.id}` : undefined}
dragListener={false} dragListener={false}
dragControls={siraKontrol} dragControls={siraKontrol}
initial={{ opacity: 0, y: 8, scale: 0.97 }} initial={{ opacity: 0, y: 8, scale: 0.97 }}
@@ -338,74 +355,3 @@ function CekmeceSatiri({
</Reorder.Item> </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"; "use client";
// Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca // 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 // çekmeceyi açar/kapatır. Sayaç değişince rozet spring ile "pulse" yapar.
// (data-listem-hedef). Sayaç değişince rozet spring ile "pulse" yapar.
import { ListChecks } from "lucide-react"; import { ListChecks } from "lucide-react";
import { motion, useReducedMotion } from "motion/react"; import { motion, useReducedMotion } from "motion/react";
@@ -21,7 +20,6 @@ export function ListemButonu() {
return ( return (
<button <button
type="button" type="button"
data-listem-hedef
onClick={cekmeceDegistir} onClick={cekmeceDegistir}
aria-expanded={acik} aria-expanded={acik}
aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`} aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`}

View File

@@ -22,13 +22,6 @@ export type ManuelTercih = {
risk: RiskSeviyesi | null; 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 // SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
const BOS_LISTE: ManuelTercih[] = []; const BOS_LISTE: ManuelTercih[] = [];
@@ -37,7 +30,6 @@ let cekmeceAcik = false;
let yuklendi = false; let yuklendi = false;
const dinleyiciler = new Set<() => void>(); const dinleyiciler = new Set<() => void>();
const ucusDinleyiciler = new Set<(istek: UcusIstegi) => void>();
function yayinla() { function yayinla() {
for (const cb of dinleyiciler) cb(); for (const cb of dinleyiciler) cb();
@@ -154,15 +146,3 @@ export function cekmeceDegistir() {
cekmeceAcik = !cekmeceAcik; cekmeceAcik = !cekmeceAcik;
yayinla(); 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 // 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 "+" // 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 // butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store).
// çekmecedeki) Listem hedefine uçar (bkz. manuel-liste/liste-cekmecesi).
import { useState } from "react"; import { useState } from "react";
import { Check, Plus, Rocket, Scale, ShieldCheck } from "lucide-react"; import Link from "next/link";
import type { Program, RankResults } from "@/lib/db"; 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 { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
import { import {
Table, Table,
@@ -21,7 +30,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
MANUEL_LISTE_MAX, MANUEL_LISTE_MAX,
ekle, ekle,
ucusBaslat,
useManuelListe, useManuelListe,
type ManuelTercih, type ManuelTercih,
} from "@/components/manuel-liste/store"; } from "@/components/manuel-liste/store";
@@ -65,6 +73,61 @@ const RISK_NOKTA: Record<RiskSeviyesi, string> = {
riskli: "bg-red-500", 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 { function efektifSira(p: Program): number | null {
return p.sira2025 ?? p.sira2024; return p.sira2025 ?? p.sira2024;
} }
@@ -84,28 +147,23 @@ function tercihYap(p: Program, adaySira: number): ManuelTercih {
export function ProgramTablosu({ export function ProgramTablosu({
sonuclar, sonuclar,
adaySira, adaySira,
tur,
}: { }: {
sonuclar: RankResults; sonuclar: RankResults;
adaySira: number; adaySira: number;
tur: PuanTuruKey;
}) { }) {
const liste = useManuelListe(); const liste = useManuelListe();
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli"); const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
const listedekiler = new Set(liste.map((t) => t.id)); const listedekiler = new Set(liste.map((t) => t.id));
const doldu = liste.length >= MANUEL_LISTE_MAX; const doldu = liste.length >= MANUEL_LISTE_MAX;
function eklemeyiDene(p: Program, buton: HTMLElement) { function eklemeyiDene(p: Program) {
const tercih = tercihYap(p, adaySira); ekle(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 },
});
} }
return ( return (
<section className="mt-16" aria-label="Sıralamanla açılan programlar"> <section className="mt-16" aria-label="Sıralamanla açılan programlar">
<div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h2 className="font-heading text-2xl font-bold"> <h2 className="font-heading text-2xl font-bold">
Sıralamanla ılan programlar Sıralamanla ılan programlar
@@ -118,7 +176,6 @@ export function ProgramTablosu({
</span> </span>
</p> </p>
</div> </div>
</div>
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */} {/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
<div className="mt-5"> <div className="mt-5">
@@ -130,6 +187,10 @@ export function ProgramTablosu({
onValueChange={(v) => setAktifDilim(v as DilimKey)} onValueChange={(v) => setAktifDilim(v as DilimKey)}
className="mt-5" className="mt-5"
> >
{/* 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> <TabsList>
{DILIMLER.map((d) => ( {DILIMLER.map((d) => (
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer"> <TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
@@ -141,6 +202,14 @@ export function ProgramTablosu({
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </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) => ( {DILIMLER.map((d) => (
<TabsContent key={d.key} value={d.key}> <TabsContent key={d.key} value={d.key}>
@@ -156,6 +225,12 @@ export function ProgramTablosu({
<TableRow> <TableRow>
<TableHead className="w-8" /> <TableHead className="w-8" />
<TableHead>Program</TableHead> <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="text-right">Taban sıra</TableHead>
<TableHead className="w-16 text-right"> <TableHead className="w-16 text-right">
<span className="sr-only">Listeye ekle</span> <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({ function ProgramSatiri({
program: p, program: p,
adaySira, adaySira,
@@ -195,10 +350,11 @@ function ProgramSatiri({
adaySira: number; adaySira: number;
listede: boolean; listede: boolean;
doldu: boolean; doldu: boolean;
onEkle: (p: Program, buton: HTMLElement) => void; onEkle: (p: Program) => void;
}) { }) {
const taban = efektifSira(p); const taban = efektifSira(p);
const risk = riskHesapla(taban, adaySira); const risk = riskHesapla(taban, adaySira);
const devlet = p.unitur === "DEVLET";
return ( return (
<TableRow> <TableRow>
@@ -212,19 +368,60 @@ function ProgramSatiri({
</TableCell> </TableCell>
<TableCell className="max-w-0 w-full"> <TableCell className="max-w-0 w-full">
<span className="block truncate text-sm font-semibold">{p.isim}</span> <span className="block truncate text-sm font-semibold">{p.isim}</span>
<span className="block truncate text-xs text-slate-500"> <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.universite}
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""} {p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span> </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>
<TableCell className="text-right text-xs tabular-nums text-slate-500"> <TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<TabanTrendi p={p} />
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"} {taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
</span>
</TableCell> </TableCell>
<TableCell className="pr-4 text-right"> <TableCell className="pr-4 text-right">
<button <button
type="button" type="button"
disabled={listede || doldu} disabled={listede || doldu}
onClick={(e) => onEkle(p, e.currentTarget)} onClick={() => onEkle(p)}
title={ title={
listede listede
? "Zaten listende" ? "Zaten listende"

View File

@@ -1,18 +1,13 @@
"use client"; "use client";
// 24'lük listenin gövdesi — metin diyeti sonrası hali: // 24'lük listenin gövdesi. Program adı, 2024/2025 tabanları ve ham trend
// AI cümleleri satırda değil, satıra dokununca açılan detayda yaşar. // herkese görünür. AI gerekçesi ile kişisel risk, kilitliBaslangic sonrasında
// Satır tek bakışta taranır: sıra, program, üniversite·il, taban, trend, risk. // ücretli katman olarak ayrılır; ücretli metin sunucuda zaten maskelidir.
// 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.
import { useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { import {
ChevronDown, ChevronDown,
Lock,
MapPin, MapPin,
Minus, Minus,
Rocket, Rocket,
@@ -85,7 +80,7 @@ const TREND_GORUNUM: Record<
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" }, 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)[] }) { function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter( const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
(n): n is { yil: number; sira: number } => n.sira != null, (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"> <div className="mt-2 flex items-center gap-2">
<svg <svg
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
className="h-10 w-[150px]" className="h-10 w-37.5"
role="img" 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")}`} 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 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 = ( const satir = (
t: RaporSonuc["tercihler"][number], t: RaporSonuc["tercihler"][number],
secenekler?: { blurPx?: number }, index: number,
) => { ) => {
const p = rapor.programlar[t.programId]; const p = rapor.programlar[t.programId];
const analizKilitli = index >= kilitIdx;
const risk = const risk =
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ?? (adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
dilimdenRisk(t.dilim); dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk]; const stil = RISK_STIL[risk];
const trend = trendYonu(p?.siraGecmisi); const trend = trendYonu(p?.siraGecmisi);
const trendG = trend ? TREND_GORUNUM[trend] : null; 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 ( return (
<li <li
@@ -201,23 +196,20 @@ export function RaporListesi({
if (el) satirRefs.current.set(t.sira, el); if (el) satirRefs.current.set(t.sira, el);
else satirRefs.current.delete(t.sira); else satirRefs.current.delete(t.sira);
}} }}
style={ className={`overflow-hidden rounded-xl border bg-white transition-[border-color,box-shadow] duration-200 hover:border-slate-300 hover:shadow-xs ${
secenekler?.blurPx analizKilitli ? "border-slate-200" : `border-l-4 ${stil.kenar}`
? { filter: `blur(${secenekler.blurPx}px)` } }`}
: undefined
}
className={`rounded-xl border border-l-4 border-slate-200 bg-white ${stil.kenar}`}
> >
<button <button
type="button" type="button"
onClick={() => tiklandi(t.sira)} onClick={() => tiklandi(t.sira)}
aria-expanded={acikMi} 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"> <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
{t.sira} {t.sira}
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0">
<span className="block truncate text-sm font-semibold"> <span className="block truncate text-sm font-semibold">
{p?.isim ?? t.programId} {p?.isim ?? t.programId}
</span> </span>
@@ -225,22 +217,47 @@ export function RaporListesi({
{p?.universite} {p?.universite}
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""} {p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
</span> </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> </span>
<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 ? (
<trendG.icon <>
className={`size-4 shrink-0 ${trendG.renk}`} <trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
aria-label={trendG.etiket} <span className={trendG.renk}>
/> {trend === "zorlasiyor"
) : null} ? "Zorlaşıyor"
<span : trend === "kolaylasiyor"
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`} ? "Rahatlıyor"
aria-label={`${DILIM_ETIKET[t.dilim]} · ${RISK_ETIKET[risk]}`} : "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>
<ChevronDown <ChevronDown
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`} className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
aria-hidden aria-hidden
@@ -248,43 +265,65 @@ export function RaporListesi({
</button> </button>
{acikMi ? ( {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"> <div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
{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 /> <stil.icon className="size-3.5" aria-hidden />
<span className="font-medium"> <span className="font-medium">
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]} {DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</span> </span>
</>
)}
{p?.unitur ? <span>· {p.unitur}</span> : null} {p?.unitur ? <span>· {p.unitur}</span> : null}
{p?.efektifSira != null ? (
<span className="sm:hidden">
· taban ~{p.efektifSira.toLocaleString("tr-TR")}.
</span>
) : null}
</div> </div>
<dl className="grid gap-2 text-sm sm:grid-cols-3"> <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">
20212025 taban trendi
</dt>
<dd className="mt-1 text-slate-700">
{p?.siraGecmisi ? (
<TrendCizgisi gecmis={p.siraGecmisi} />
) : (
"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"> <dt className="text-xs font-semibold text-slate-500">
Neden listede? Neden listede?
</dt> </dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd> <dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div> </div>
<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"> <dt className="text-xs font-semibold text-slate-500">
Risk notu Risk ve AI yorumu
</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
</dt> </dt>
<dd className="mt-1 text-slate-700"> <dd className="mt-1 text-slate-700">
{t.trendOzeti} {t.riskNotu} {t.trendOzeti}
{p?.siraGecmisi ? (
<TrendCizgisi gecmis={p.siraGecmisi} />
) : null}
</dd> </dd>
</div> </div>
</>
)}
</dl> </dl>
{onHaritadaGor && p?.il ? ( {onHaritadaGor && p?.il ? (
<button <button
@@ -304,27 +343,42 @@ export function RaporListesi({
return ( return (
<div> <div>
<p className="text-xs text-slate-500"> <div className="flex flex-wrap items-end justify-between gap-2">
Satıra dokun; gerekçe, risk notu ve 4 yıllık trend ılsın. <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> </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> </div>
{kilitIdx < rapor.tercihler.length ? (
<p className="text-xs font-medium text-emerald-700">
Programlar ve ham veriler ücretsiz
</p>
) : null} ) : null}
</div> </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 { Button } from "@/components/ui/button";
import { import {
HARITA_GENISLIK, HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK, HARITA_YUKSEKLIK,
normalizeIlAdi, normalizeIlAdi,
} from "@/lib/harita"; } from "@/lib/harita";
@@ -45,13 +46,11 @@ const RISK_RENK: Record<RiskSeviyesi, string> = {
riskli: "oklch(0.637 0.237 25.3)", // red-500 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 = { const TAM_GORUNUM = {
x: 0, x: 0,
y: 138, y: HARITA_UST_KIRPMA,
w: HARITA_GENISLIK, w: HARITA_GENISLIK,
h: HARITA_YUKSEKLIK - 138, h: HARITA_YUKSEKLIK - HARITA_UST_KIRPMA,
}; };
type ViewBox = typeof TAM_GORUNUM; 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_GENISLIK = 1050;
export const HARITA_YUKSEKLIK = 585; 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 } { export function latLngToXy([lat, lng]: LatLng): { x: number; y: number } {
return { return {

View File

@@ -1,31 +1,18 @@
// Paketsiz kullanıcıya giden raporun sunucu tarafı maskesi. // 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 // YÖK Atlas'ın ham program ve taban sıralaması verileri ücretsiz kalır.
// hiç inmemeli: ilk ACIK_SATIR tercih gerçek kalır, gerisinin metinleri // Yalnızca AI'ın ürettiği kişisel gerekçe/risk/trend yorumu maskelenir;
// makul görünen placeholder'larla değiştirilir. Risk rengini besleyen // böylece istemciye ücretli analiz metni hiç inmez.
// 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.
import type { RaporSonuc } from "./ai/rapor"; import type { RaporSonuc } from "./ai/rapor";
export const ACIK_SATIR = 3; 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 = 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 = 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 = 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. */ /** genelDegerlendirme'nin yalnızca ilk 1-2 cümlesini bırakır. */
function degerlendirmeKirp(metin: string): string { 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 { return {
tercihler, tercihler,
genelDegerlendirme: degerlendirmeKirp(rapor.genelDegerlendirme), 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, uyarilar: rapor.uyarilar,
programlar, // Program adı, üniversite ve 20212025 taban sıralamaları ham veridir.
programlar: rapor.programlar,
}; };
} }