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 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 2024–2025 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]">
|
||||||
<RaporListesi
|
<section className="min-w-0 rounded-2xl border border-slate-200 bg-slate-50/60 p-3 sm:p-5">
|
||||||
rapor={rapor}
|
<RaporListesi
|
||||||
adaySira={params.sira}
|
rapor={rapor}
|
||||||
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
adaySira={params.sira}
|
||||||
kilitOverlay={
|
kilitliBaslangic={user.hasPaket ? undefined : ACIK_SATIR}
|
||||||
<>
|
kilitOverlay={
|
||||||
<Lock className="size-8 text-slate-500" aria-hidden />
|
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="max-w-sm text-center font-heading text-lg font-bold text-slate-900">
|
<div className="flex gap-3">
|
||||||
24 tercihin {24 - ACIK_SATIR}'i kilidin ardında
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-orange-100 text-orange-700">
|
||||||
</p>
|
<Lock className="size-4" aria-hidden />
|
||||||
<Button
|
</span>
|
||||||
asChild
|
<div>
|
||||||
size="lg"
|
<p className="font-heading font-bold text-slate-900">
|
||||||
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
Ham veri burada bitmiyor — aşağıdaki tüm satırlar açık
|
||||||
>
|
</p>
|
||||||
<Link href="/paket">299 TL — Tüm listeyi aç</Link>
|
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||||
</Button>
|
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">
|
<div className="lg:sticky lg:top-28">
|
||||||
<SohbetClient baslangicKredi={user.creditBalance} />
|
{user.hasPaket ? (
|
||||||
|
<SohbetClient baslangicKredi={user.creditBalance} />
|
||||||
|
) : (
|
||||||
|
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-slate-100 p-6">
|
||||||
|
<div className="flex items-center gap-2 text-primary">
|
||||||
|
<Sparkles className="size-4" aria-hidden />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide">
|
||||||
|
Tercih Dönemi Paketi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 font-heading text-xl font-bold tracking-tight">
|
||||||
|
Tablo ücretsiz.
|
||||||
|
<br />
|
||||||
|
Karar desteği pakette.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
Sadece veriyi göstermez; hangi tercihin neden mantıklı
|
||||||
|
olduğunu sıralamana göre 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="mt-5 h-12 w-full cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 active:scale-[0.97] hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link href="/paket">
|
||||||
|
Kişisel analizi aç
|
||||||
|
<ArrowRight data-icon="inline-end" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-4 text-[11px] text-slate-500">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ShieldCheck className="size-3.5" aria-hidden />
|
||||||
|
iyzico güvenli ödeme
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Bot className="size-3.5" aria-hidden />
|
||||||
|
Gerçek YÖK verisi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,68 +1,140 @@
|
|||||||
"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
|
aria-hidden
|
||||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-indigo-500"
|
className="pointer-events-none absolute -right-16 -top-24 size-64 rounded-full bg-blue-500/25 blur-3xl"
|
||||||
aria-hidden
|
/>
|
||||||
>
|
<div
|
||||||
<Sparkles className="size-5 text-white" />
|
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>
|
||||||
<TypingAnimation
|
|
||||||
as="p"
|
{hedefListem ? (
|
||||||
words={CUMLELER}
|
<Button asChild size="lg" className={ctaSinif}>
|
||||||
loop
|
<Link href="/listem">
|
||||||
typeSpeed={45}
|
Listeni aç
|
||||||
deleteSpeed={20}
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
pauseDelay={2400}
|
</Link>
|
||||||
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"
|
</Button>
|
||||||
/>
|
) : (
|
||||||
</div>
|
<Button
|
||||||
{hedefListem ? (
|
size="lg"
|
||||||
<Button asChild className={ctaSinif}>
|
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
|
||||||
<Link href="/listem">
|
className={ctaSinif}
|
||||||
Listeni aç
|
>
|
||||||
|
Listemi oluştur
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<Button
|
|
||||||
onClick={() => window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT))}
|
|
||||||
className={ctaSinif}
|
|
||||||
>
|
|
||||||
Listemi oluştur
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}'i kilidin ardında
|
className="mt-1 size-5 shrink-0 text-orange-700"
|
||||||
</p>
|
aria-hidden
|
||||||
<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ı
|
<div>
|
||||||
+ 2 revizyon hakkı + 60 kredi seni bekliyor.
|
<p className="font-heading font-bold text-slate-900">
|
||||||
</p>
|
Tüm program ve taban verileri ücretsiz
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 max-w-xl text-sm text-slate-600">
|
||||||
|
Paket; kalan {24 - ACIK_SATIR} tercih için kişisel risk,
|
||||||
|
gerekçe, PDF, 2 revizyon ve 60 AI danışman sorusunu açar.
|
||||||
|
</p>
|
||||||
|
{kredi > 0 ? (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Kalan {kredi} deneme kredin hesabında duruyor.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<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 aç</Link>
|
<Link href="/paket">299 TL — Analizleri aç</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">
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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,16 +215,20 @@ 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?");
|
||||||
setDurum("bos");
|
modalaDon();
|
||||||
setElleAcik(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hata sonrası panelden modala dönüş. */
|
||||||
|
function modalaDon() {
|
||||||
|
setDurum("bos");
|
||||||
|
setElleAcik(true);
|
||||||
|
}
|
||||||
|
|
||||||
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -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,76 +280,119 @@ 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. */}
|
||||||
{durum !== "bos" ? (
|
<div ref={bolumRef} className="relative">
|
||||||
<ListePaneli
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
durum={durum}
|
<motion.div
|
||||||
rapor={null}
|
key={panelAnahtari}
|
||||||
hasPaket={paketli}
|
initial={
|
||||||
kredi={kredi}
|
hareketiAzalt
|
||||||
sira={sira}
|
? { opacity: 0 }
|
||||||
acilis={acilis}
|
: {
|
||||||
onRevize={async (feedback) => {
|
opacity: 0,
|
||||||
const sonuc = await listeRevize({
|
transform: "translate3d(0, 8px, 0)",
|
||||||
feedback,
|
}
|
||||||
requestId: crypto.randomUUID(),
|
}
|
||||||
});
|
animate={{
|
||||||
if (sonuc.ok) {
|
opacity: 1,
|
||||||
setRapor({
|
transform: "translate3d(0, 0, 0)",
|
||||||
rapor: sonuc.rapor,
|
transition: hareketiAzalt
|
||||||
params: sonuc.params,
|
? { duration: 0.12, ease: "easeOut" }
|
||||||
revisionCount: sonuc.revisionCount,
|
: {
|
||||||
});
|
duration: 0.28,
|
||||||
setKredi(sonuc.kredi);
|
ease: [0.23, 1, 0.32, 1],
|
||||||
toast.success("Listen yeniden kuruldu.");
|
},
|
||||||
} else {
|
}}
|
||||||
toast.error(sonuc.error);
|
exit={
|
||||||
|
hareketiAzalt
|
||||||
|
? {
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: 0.08, ease: "easeOut" },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 0,
|
||||||
|
transform: "translate3d(0, -4px, 0)",
|
||||||
|
transition: {
|
||||||
|
duration: 0.14,
|
||||||
|
ease: [0.4, 0, 0.7, 0.2],
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sonuc.ok;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : rapor ? (
|
|
||||||
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
|
||||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
|
||||||
24 tercihlik listen hazır
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
|
||||||
Yapay zekânın kurduğu risk analizli listen Listem sayfasında seni
|
|
||||||
bekliyor.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="lg"
|
|
||||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
>
|
||||||
<Link href="/listem">Listeni gör</Link>
|
{durum !== "bos" ? (
|
||||||
</Button>
|
<ListePaneli
|
||||||
</section>
|
durum={durum}
|
||||||
) : (
|
rapor={null}
|
||||||
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
|
hasPaket={paketli}
|
||||||
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
kredi={kredi}
|
||||||
Bu listeyi 24 tercihlik plana çevirelim mi?
|
sira={sira}
|
||||||
</h2>
|
acilis={acilis}
|
||||||
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
onRevize={async (feedback) => {
|
||||||
Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli,
|
const sonuc = await listeRevize({
|
||||||
dengeli bir tercih listesi kuralım — her satır için yerleşme riski
|
feedback,
|
||||||
analiziyle.
|
requestId: crypto.randomUUID(),
|
||||||
</p>
|
});
|
||||||
<Button
|
if (sonuc.ok) {
|
||||||
size="lg"
|
setRapor({
|
||||||
onClick={() => acikDegisti(true)}
|
rapor: sonuc.rapor,
|
||||||
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
params: sonuc.params,
|
||||||
>
|
revisionCount: sonuc.revisionCount,
|
||||||
<Sparkles className="size-4" aria-hidden />
|
});
|
||||||
Listemi oluştur
|
setKredi(sonuc.kredi);
|
||||||
</Button>
|
toast.success("Listen yeniden kuruldu.");
|
||||||
</section>
|
} else {
|
||||||
)}
|
toast.error(sonuc.error);
|
||||||
|
}
|
||||||
|
return sonuc.ok;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : rapor ? (
|
||||||
|
<section className="mt-16 rounded-2xl border border-slate-200 bg-white p-8 text-center sm:p-12">
|
||||||
|
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||||
|
24 tercihlik listen hazır
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||||
|
Yapay zekânın kurduğu risk analizli listen Listem sayfasında
|
||||||
|
seni bekliyor.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link href="/listem">Listeni gör</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
|
||||||
|
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
|
||||||
|
Bu listeyi 24 tercihlik plana çevirelim mi?
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto mt-3 max-w-xl text-white/90">
|
||||||
|
Birkaç adımda ilgi alanların ve şehir tercihine göre
|
||||||
|
gerekçeli, dengeli bir tercih listesi kuralım — her satır için
|
||||||
|
yerleşme riski analiziyle.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={() => acikDegisti(true)}
|
||||||
|
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" aria-hidden />
|
||||||
|
Listemi oluştur
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sabit alt çağrı kartı — liste/panel yokken ve kart kapatılmadıysa */}
|
{/* 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}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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ç"
|
||||||
|
|||||||
@@ -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,151 +84,159 @@ export function ListeCekmecesi() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<motion.aside
|
||||||
<UcusKatmani />
|
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
|
||||||
|
animate={azMotion ? { opacity: 1 } : { y: 0 }}
|
||||||
<AnimatePresence>
|
exit={
|
||||||
{acik ? (
|
azMotion
|
||||||
<motion.aside
|
? { opacity: 0, transition: { duration: 0.15 } }
|
||||||
key="cekmece"
|
: {
|
||||||
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
|
y: "calc(100% + 16px)",
|
||||||
animate={azMotion ? { opacity: 1 } : { y: 0 }}
|
transition: {
|
||||||
exit={
|
duration: 0.25,
|
||||||
azMotion
|
ease: [0.32, 0.72, 0, 1],
|
||||||
? { opacity: 0, transition: { duration: 0.15 } }
|
},
|
||||||
: {
|
|
||||||
y: "calc(100% + 16px)",
|
|
||||||
transition: {
|
|
||||||
duration: 0.25,
|
|
||||||
ease: [0.32, 0.72, 0, 1],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
|
}
|
||||||
// layoutRoot: sheet'in kendi y-transform animasyonu, içindeki
|
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
|
||||||
// Reorder satırlarının layout ölçümünü bozmasın (satırlar aksi
|
onAnimationComplete={() => setOturdu(true)}
|
||||||
// halde giriş anındaki offset'i kalıcı transform olarak taşıyor)
|
drag={azMotion ? false : "y"}
|
||||||
layoutRoot
|
dragListener={false}
|
||||||
drag={azMotion ? false : "y"}
|
dragControls={sheetKontrol}
|
||||||
dragListener={false}
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
dragControls={sheetKontrol}
|
dragElastic={{ top: 0, bottom: 0.6 }}
|
||||||
dragConstraints={{ top: 0, bottom: 0 }}
|
onDragEnd={surukleyinceKapat}
|
||||||
dragElastic={{ top: 0, bottom: 0.6 }}
|
aria-label="Manuel tercih listem"
|
||||||
onDragEnd={surukleyinceKapat}
|
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
|
||||||
aria-label="Manuel tercih listem"
|
>
|
||||||
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
|
{/* layout: satır silinince/eklenince kart yüksekliği spring ile oturur.
|
||||||
>
|
Doğrudan çocuklar layout="position" taşır ki ölçekleme sırasında
|
||||||
<div className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
|
içerik yamulmasın (scale-correction zinciri). */}
|
||||||
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
|
<motion.div
|
||||||
<div
|
layout={oturdu}
|
||||||
onPointerDown={(e) => {
|
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
|
||||||
if (!azMotion) sheetKontrol.start(e);
|
className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl"
|
||||||
}}
|
>
|
||||||
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
|
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
|
||||||
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (!azMotion) sheetKontrol.start(e);
|
||||||
|
}}
|
||||||
|
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Başlık + doluluk */}
|
||||||
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
className="border-b border-slate-100 px-5 pb-4 pt-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
|
||||||
|
<ListChecks className="size-5 text-primary" aria-hidden />
|
||||||
|
Listem
|
||||||
|
<span className="font-heading text-sm font-bold tabular-nums text-slate-400">
|
||||||
|
{liste.length}/{MANUEL_LISTE_MAX}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{liste.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={temizle}
|
||||||
|
title="Listeyi temizle"
|
||||||
|
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
<span className="sr-only">Listeyi temizle</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cekmeceKapat}
|
||||||
|
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
||||||
>
|
>
|
||||||
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
|
<X className="size-4" aria-hidden />
|
||||||
</div>
|
<span className="sr-only">Kapat</span>
|
||||||
|
</button>
|
||||||
{/* Başlık + doluluk */}
|
|
||||||
<div className="border-b border-slate-100 px-5 pb-4 pt-1">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
|
|
||||||
<ListChecks className="size-5 text-primary" aria-hidden />
|
|
||||||
Listem
|
|
||||||
<span
|
|
||||||
data-cekmece-hedef
|
|
||||||
className="font-heading text-sm font-bold tabular-nums text-slate-400"
|
|
||||||
>
|
|
||||||
{liste.length}/{MANUEL_LISTE_MAX}
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{liste.length > 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={temizle}
|
|
||||||
title="Listeyi temizle"
|
|
||||||
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" aria-hidden />
|
|
||||||
<span className="sr-only">Listeyi temizle</span>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={cekmeceKapat}
|
|
||||||
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
<X className="size-4" aria-hidden />
|
|
||||||
<span className="sr-only">Kapat</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={liste.length}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={MANUEL_LISTE_MAX}
|
|
||||||
aria-label="Liste doluluğu"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="h-full rounded-full bg-primary"
|
|
||||||
animate={{ width: `${doluluk}%` }}
|
|
||||||
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
|
||||||
Sıralamanla ulaşabildiğin programlardan kendi listen —
|
|
||||||
tutamaktan sürükleyip sırala, sağa kaydırıp sil.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gövde: sürüklenebilir satırlar */}
|
|
||||||
{liste.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center">
|
|
||||||
<ListChecks className="size-8 text-slate-300" aria-hidden />
|
|
||||||
<p className="font-heading text-sm font-bold text-slate-700">
|
|
||||||
Listen henüz boş
|
|
||||||
</p>
|
|
||||||
<p className="text-xs leading-relaxed text-slate-500">
|
|
||||||
Sonuç sayfasındaki tablodan <span aria-hidden>+</span> ile
|
|
||||||
program ekle; 24 tercihlik listen burada birikir.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Reorder.Group
|
|
||||||
axis="y"
|
|
||||||
values={liste}
|
|
||||||
onReorder={yenidenSirala}
|
|
||||||
layoutScroll
|
|
||||||
as="ol"
|
|
||||||
className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-4"
|
|
||||||
>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{liste.map((t, i) => (
|
|
||||||
<CekmeceSatiri key={t.id} tercih={t} sira={i + 1} />
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Reorder.Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Alt bant: AI danışman köprüsü */}
|
|
||||||
<div className="border-t border-slate-100 px-5 py-4">
|
|
||||||
<Link
|
|
||||||
href="/listem"
|
|
||||||
onClick={cekmeceKapat}
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
|
||||||
>
|
|
||||||
<Sparkles className="size-4" aria-hidden />
|
|
||||||
AI listeni ve danışmanı aç
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.aside>
|
</div>
|
||||||
) : null}
|
<div
|
||||||
</AnimatePresence>
|
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
|
||||||
</>
|
role="progressbar"
|
||||||
|
aria-valuenow={liste.length}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={MANUEL_LISTE_MAX}
|
||||||
|
aria-label="Liste doluluğu"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
animate={{ width: `${doluluk}%` }}
|
||||||
|
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Sıralamanla ulaşabildiğin programlardan kendi listen — tutamaktan
|
||||||
|
sürükleyip sırala, sağa kaydırıp sil.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Gövde: sürüklenebilir satırlar */}
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center"
|
||||||
|
>
|
||||||
|
<ListChecks className="size-8 text-slate-300" aria-hidden />
|
||||||
|
<p className="font-heading text-sm font-bold text-slate-700">
|
||||||
|
Listen henüz boş
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-relaxed text-slate-500">
|
||||||
|
Sonuç sayfasındaki tablodan <span aria-hidden>+</span> ile
|
||||||
|
program ekle; 24 tercihlik listen burada birikir.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={liste}
|
||||||
|
onReorder={yenidenSirala}
|
||||||
|
layout="position"
|
||||||
|
layoutScroll
|
||||||
|
as="ol"
|
||||||
|
className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto px-4 py-4"
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{liste.map((t, i) => (
|
||||||
|
<CekmeceSatiri
|
||||||
|
key={t.id}
|
||||||
|
tercih={t}
|
||||||
|
sira={i + 1}
|
||||||
|
projeksiyonAcik={oturdu}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alt bant: AI danışman köprüsü */}
|
||||||
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
className="border-t border-slate-100 px-5 py-4"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/listem"
|
||||||
|
onClick={cekmeceKapat}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" aria-hidden />
|
||||||
|
AI listeni ve danışmanı aç
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,40 +147,34 @@ 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 açılan programlar
|
||||||
Sıralamanla açılan programlar
|
</h2>
|
||||||
</h2>
|
<p className="mt-1 max-w-2xl text-sm text-slate-500">
|
||||||
<p className="mt-1 max-w-2xl text-sm text-slate-500">
|
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
|
||||||
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
|
sıralamalarına göre ulaşabildiğin bölümler.{" "}
|
||||||
sıralamalarına göre ulaşabildiğin bölümler.{" "}
|
<span className="font-medium text-slate-700">
|
||||||
<span className="font-medium text-slate-700">
|
+ ile beğendiklerini kendi 24'lük listene at.
|
||||||
+ ile beğendiklerini kendi 24'lük listene at.
|
</span>
|
||||||
</span>
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
|
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
|
||||||
@@ -130,17 +187,29 @@ export function ProgramTablosu({
|
|||||||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||||||
className="mt-5"
|
className="mt-5"
|
||||||
>
|
>
|
||||||
<TabsList>
|
{/* Kontrol çubuğu: tabloyu süzen her şey tablonun hemen üstünde —
|
||||||
{DILIMLER.map((d) => (
|
solda dilim sekmeleri (birincil), sağda puan türü (ikincil filtre).
|
||||||
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
|
Dar ekranda alt alta sarar. */}
|
||||||
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
|
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||||
{d.etiket}
|
<TabsList>
|
||||||
<span className="text-xs tabular-nums text-slate-400">
|
{DILIMLER.map((d) => (
|
||||||
{sonuclar[d.key].length}
|
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
|
||||||
</span>
|
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
|
||||||
</TabsTrigger>
|
{d.etiket}
|
||||||
))}
|
<span className="text-xs tabular-nums text-slate-400">
|
||||||
</TabsList>
|
{sonuclar[d.key].length}
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
||||||
|
Puan türü
|
||||||
|
</span>
|
||||||
|
{/* scroll={false}: tür değişince sayfa konumu korunur */}
|
||||||
|
<PuanTuruSecici sira={adaySira} aktif={tur} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{DILIMLER.map((d) => (
|
{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 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({
|
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">
|
||||||
{p.universite}
|
<span
|
||||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
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>
|
</span>
|
||||||
</TableCell>
|
</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">
|
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<TabanTrendi p={p} />
|
||||||
|
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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"
|
||||||
|
|||||||
@@ -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>
|
||||||
<span className="hidden shrink-0 text-xs tabular-nums text-slate-500 sm:block">
|
<span className="hidden text-right text-xs tabular-nums text-slate-600 xl:block">
|
||||||
{p?.efektifSira != null
|
{sira2024?.toLocaleString("tr-TR") ?? "—"}
|
||||||
? `~${p.efektifSira.toLocaleString("tr-TR")}.`
|
</span>
|
||||||
: ""}
|
<span className="hidden text-right text-xs font-semibold tabular-nums text-slate-900 xl:block">
|
||||||
|
{sira2025?.toLocaleString("tr-TR") ?? "—"}
|
||||||
|
</span>
|
||||||
|
<span className="hidden items-center gap-1.5 text-xs xl:flex">
|
||||||
|
{trendG ? (
|
||||||
|
<>
|
||||||
|
<trendG.icon className={`size-4 shrink-0 ${trendG.renk}`} aria-hidden />
|
||||||
|
<span className={trendG.renk}>
|
||||||
|
{trend === "zorlasiyor"
|
||||||
|
? "Zorlaşıyor"
|
||||||
|
: trend === "kolaylasiyor"
|
||||||
|
? "Rahatlıyor"
|
||||||
|
: "Stabil"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">Veri yok</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="hidden items-center gap-1.5 text-xs font-medium xl:flex">
|
||||||
|
{analizKilitli ? (
|
||||||
|
<>
|
||||||
|
<Lock className="size-3.5 text-slate-400" aria-hidden />
|
||||||
|
<span className="text-slate-500">Pakette</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className={`size-2.5 rounded-full ${stil.nokta}`} aria-hidden />
|
||||||
|
<span>{RISK_ETIKET[risk]}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{trendG ? (
|
|
||||||
<trendG.icon
|
|
||||||
className={`size-4 shrink-0 ${trendG.renk}`}
|
|
||||||
aria-label={trendG.etiket}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span
|
|
||||||
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
|
|
||||||
aria-label={`${DILIM_ETIKET[t.dilim]} · ${RISK_ETIKET[risk]}`}
|
|
||||||
/>
|
|
||||||
<ChevronDown
|
<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">
|
||||||
<stil.icon className="size-3.5" aria-hidden />
|
{analizKilitli ? (
|
||||||
<span className="font-medium">
|
<>
|
||||||
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
|
<Lock className="size-3.5" aria-hidden />
|
||||||
</span>
|
<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?.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">
|
<dt className="text-xs font-semibold text-slate-500">
|
||||||
Neden listede?
|
2021–2025 taban trendi
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-50 p-3">
|
|
||||||
<dt className="text-xs font-semibold text-slate-500">
|
|
||||||
Risk notu
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-slate-50 p-3">
|
|
||||||
<dt className="text-xs font-semibold text-slate-500">
|
|
||||||
4 yıllık trend
|
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-1 text-slate-700">
|
<dd className="mt-1 text-slate-700">
|
||||||
{t.trendOzeti}
|
|
||||||
{p?.siraGecmisi ? (
|
{p?.siraGecmisi ? (
|
||||||
<TrendCizgisi gecmis={p.siraGecmisi} />
|
<TrendCizgisi gecmis={p.siraGecmisi} />
|
||||||
) : null}
|
) : (
|
||||||
|
"Yeterli veri yok."
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
{analizKilitli ? (
|
||||||
|
<div className="flex flex-col justify-center rounded-lg border border-dashed border-slate-300 bg-white p-3 sm:col-span-2">
|
||||||
|
<dt className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||||
|
<Lock className="size-3.5" aria-hidden />
|
||||||
|
AI karar desteği
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-slate-600">
|
||||||
|
Bu bölümün neden listende olduğunu ve sıralamana özel yerleşme
|
||||||
|
riskini paketle gör.
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||||
|
<dt className="text-xs font-semibold text-slate-500">
|
||||||
|
Neden listede?
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||||
|
<dt className="text-xs font-semibold text-slate-500">
|
||||||
|
Risk ve AI yorumu
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-slate-700">
|
||||||
|
{t.riskNotu} {t.trendOzeti}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</dl>
|
</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 açılsın.
|
<div>
|
||||||
</p>
|
<h2 className="font-heading text-lg font-bold">24 tercihin ve taban verileri</h2>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
<ol className="mt-2 space-y-2">{acikTercihler.map((t) => satir(t))}</ol>
|
Satıra dokunarak 2021–2025 trendini inceleyebilirsin.
|
||||||
|
</p>
|
||||||
{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>
|
||||||
) : null}
|
{kilitIdx < rapor.tercihler.length ? (
|
||||||
|
<p className="text-xs font-medium text-emerald-700">
|
||||||
|
Programlar ve ham veriler ücretsiz
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 hidden grid-cols-[auto_minmax(220px,1fr)_92px_92px_112px_82px_auto] gap-x-3 px-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500 xl:grid">
|
||||||
|
<span className="w-7">#</span>
|
||||||
|
<span>Program</span>
|
||||||
|
<span className="text-right">2024 taban</span>
|
||||||
|
<span className="text-right">2025 taban</span>
|
||||||
|
<span>Ham trend</span>
|
||||||
|
<span>AI risk</span>
|
||||||
|
<span className="w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="mt-2 flex flex-col gap-2">
|
||||||
|
{rapor.tercihler.map((t, index) => (
|
||||||
|
<Fragment key={t.sira}>
|
||||||
|
{index === kilitIdx && kilitOverlay ? (
|
||||||
|
<li className="my-2 list-none rounded-2xl border border-orange-200 bg-orange-50/70 p-5">
|
||||||
|
{kilitOverlay}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{satir(t, index)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 2021–2025 taban sıralamaları ham veridir.
|
||||||
|
programlar: rapor.programlar,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user