Update UI components and database schema
All checks were successful
Deploy / deploy (push) Successful in 2m38s
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:
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
@@ -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 2024–2025 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]">
|
||||
<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={
|
||||
<>
|
||||
<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}'i kilidin ardında
|
||||
<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 açı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 aç
|
||||
<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 açıklar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<ul className="flex flex-col gap-3 text-sm">
|
||||
{[
|
||||
"24 tercihin tamamında kişisel risk analizi",
|
||||
"Her program için kısa, gerekçeli 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="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 aç</Link>
|
||||
<Link href="/paket">
|
||||
Kişisel analizi aç
|
||||
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="lg:sticky lg:top-28">
|
||||
<SohbetClient baslangicKredi={user.creditBalance} />
|
||||
<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>
|
||||
|
||||
@@ -1,54 +1,124 @@
|
||||
"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>
|
||||
<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"
|
||||
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>
|
||||
|
||||
{hedefListem ? (
|
||||
<Button asChild className={ctaSinif}>
|
||||
<Button asChild size="lg" className={ctaSinif}>
|
||||
<Link href="/listem">
|
||||
Listeni aç
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
@@ -56,6 +126,7 @@ export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
|
||||
className={ctaSinif}
|
||||
>
|
||||
@@ -63,6 +134,7 @@ export function FunnelBanner({ hedefListem }: { hedefListem: boolean }) {
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}'i kilidin ardında
|
||||
<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="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 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 aç</Link>
|
||||
<Link href="/paket">299 TL — Analizleri aç</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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,15 +215,19 @@ 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?");
|
||||
modalaDon();
|
||||
}
|
||||
}
|
||||
|
||||
/** Hata sonrası panelden modala dönüş. */
|
||||
function modalaDon() {
|
||||
setDurum("bos");
|
||||
setElleAcik(true);
|
||||
}
|
||||
}
|
||||
|
||||
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
||||
try {
|
||||
@@ -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,12 +280,52 @@ 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. */}
|
||||
<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" ? (
|
||||
<ListePaneli
|
||||
durum={durum}
|
||||
@@ -266,8 +359,8 @@ export function SihirbazBolumu({
|
||||
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.
|
||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||
seni bekliyor.
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
@@ -283,9 +376,9 @@ export function SihirbazBolumu({
|
||||
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.
|
||||
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"
|
||||
@@ -297,6 +390,9 @@ export function SihirbazBolumu({
|
||||
</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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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ç"
|
||||
|
||||
@@ -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,13 +84,7 @@ 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={
|
||||
@@ -98,10 +99,7 @@ export function ListeCekmecesi() {
|
||||
}
|
||||
}
|
||||
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
|
||||
onAnimationComplete={() => setOturdu(true)}
|
||||
drag={azMotion ? false : "y"}
|
||||
dragListener={false}
|
||||
dragControls={sheetKontrol}
|
||||
@@ -111,27 +109,35 @@ export function ListeCekmecesi() {
|
||||
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">
|
||||
{/* 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 */}
|
||||
<div
|
||||
<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 />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 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">
|
||||
<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"
|
||||
>
|
||||
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
|
||||
{liste.length}/{MANUEL_LISTE_MAX}
|
||||
</span>
|
||||
</h2>
|
||||
@@ -172,14 +178,17 @@ export function ListeCekmecesi() {
|
||||
/>
|
||||
</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.
|
||||
Sıralamanla ulaşabildiğin programlardan kendi listen — tutamaktan
|
||||
sürükleyip sırala, sağa kaydırıp sil.
|
||||
</p>
|
||||
</div>
|
||||
</motion.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">
|
||||
<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ş
|
||||
@@ -188,26 +197,35 @@ export function ListeCekmecesi() {
|
||||
Sonuç sayfasındaki tablodan <span aria-hidden>+</span> ile
|
||||
program ekle; 24 tercihlik listen burada birikir.
|
||||
</p>
|
||||
</div>
|
||||
</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-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}>
|
||||
{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>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
href="/listem"
|
||||
onClick={cekmeceKapat}
|
||||
@@ -216,12 +234,9 @@ export function ListeCekmecesi() {
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
AI listeni ve danışmanı aç
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.aside>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,28 +147,23 @@ 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 açılan programlar
|
||||
@@ -118,7 +176,6 @@ export function ProgramTablosu({
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
|
||||
<div className="mt-5">
|
||||
@@ -130,6 +187,10 @@ export function ProgramTablosu({
|
||||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||||
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>
|
||||
{DILIMLER.map((d) => (
|
||||
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
|
||||
@@ -141,6 +202,14 @@ export function ProgramTablosu({
|
||||
</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 2021–2025 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">
|
||||
<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">
|
||||
<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"
|
||||
|
||||
@@ -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 className="hidden shrink-0 text-xs tabular-nums text-slate-500 sm:block">
|
||||
{p?.efektifSira != null
|
||||
? `~${p.efektifSira.toLocaleString("tr-TR")}.`
|
||||
: ""}
|
||||
</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.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]}`}
|
||||
/>
|
||||
<>
|
||||
<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>
|
||||
<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">
|
||||
{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">
|
||||
2021–2025 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">
|
||||
Neden listede?
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
|
||||
</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">
|
||||
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
|
||||
Risk ve AI yorumu
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">
|
||||
{t.trendOzeti}
|
||||
{p?.siraGecmisi ? (
|
||||
<TrendCizgisi gecmis={p.siraGecmisi} />
|
||||
) : null}
|
||||
{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 açılsın.
|
||||
<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 2021–2025 trendini inceleyebilirsin.
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 2021–2025 taban sıralamaları ham veridir.
|
||||
programlar: rapor.programlar,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user