Sihirbaz akışı ana sayfaya taşındı: sıralama girilince modal orada açılır
Some checks failed
Deploy / deploy (push) Failing after 1m30s

- Ortak SihirbazAdimlar bileşeni (3 adımlık seçim UI'ı) çıkarıldı;
  hem hero modalı hem /sonuc modalı kullanıyor
- /api/facetler: sıraya göre kategori/il/üniversite tipi facet'leri
- HeroForm: "Listemi oluştur" artık modalı açar; seçimler tamamlanınca
  localStorage'a (SIHIRBAZ_STORAGE_KEY) yazılıp /sonuc'a yönlendirilir
- /sonuc: modal yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır;
  kayıtlı seçimler prefill olarak kullanılır

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-07-26 02:30:44 +03:00
parent fc146b0e18
commit 2c02e3d7e9
5 changed files with 397 additions and 498 deletions

View File

@@ -0,0 +1,22 @@
import { NextResponse, type NextRequest } from "next/server";
import { PUAN_TURLERI, rankWindowFacets, type PuanTuruKey } from "@/lib/db";
// Ana sayfadaki sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite
// tipi facet'leri. /sonuc bu veriyi server component'te doğrudan alır; burada
// client'tan sıra girildikten sonra çekilir.
export function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const sira = Number.parseInt(searchParams.get("sira") ?? "", 10);
const turParam = searchParams.get("tur") ?? "say";
const tur: PuanTuruKey =
turParam in PUAN_TURLERI ? (turParam as PuanTuruKey) : "say";
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return NextResponse.json(
{ error: "Geçerli bir sıralama gerekli." },
{ status: 400 },
);
}
return NextResponse.json({ facetler: rankWindowFacets(sira, tur) });
}

View File

@@ -1,22 +1,11 @@
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { import { ArrowLeft } from "lucide-react";
ArrowLeft,
MapPin,
Minus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
X,
} from "lucide-react";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { import {
PUAN_TURLERI, PUAN_TURLERI,
searchByRank, searchByRank,
rankWindowFacets, rankWindowFacets,
type Program,
type PuanTuruKey, type PuanTuruKey,
} from "@/lib/db"; } from "@/lib/db";
import { getSession, getCurrentUser } from "@/lib/session"; import { getSession, getCurrentUser } from "@/lib/session";
@@ -24,24 +13,12 @@ import { appDb, schema } from "@/lib/appdb";
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 { SihirbazBolumu } from "./sihirbaz-cagri-karti"; import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type { import type { MevcutRapor, OnizlemeSatir } from "./sihirbaz-modal";
MevcutRapor,
OnizlemeSatir,
} from "./sihirbaz-modal";
import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sıralamana Uygun Programlar — KolayTercih", title: "Tercih Listeni Kur — KolayTercih",
}; };
const TUR_LABELS: Record<PuanTuruKey, string> = { const TUR_LABELS: Record<PuanTuruKey, string> = {
@@ -52,106 +29,12 @@ const TUR_LABELS: Record<PuanTuruKey, string> = {
tyt: "TYT (Önlisans)", tyt: "TYT (Önlisans)",
}; };
function TrendIcon({ p }: { p: Program }) {
if (p.sira2025 == null || p.sira2024 == null) {
return <Minus className="size-4 text-slate-400" aria-label="Veri yok" />;
}
const degisim = (p.sira2024 - p.sira2025) / p.sira2024;
// taban sıralaması küçülüyorsa bölüm zorlaşıyor demektir
if (degisim > 0.05) {
return (
<span className="inline-flex items-center gap-1 text-orange-600">
<TrendingUp className="size-4" aria-hidden />
<span className="text-xs">zorlaşıyor</span>
</span>
);
}
if (degisim < -0.05) {
return (
<span className="inline-flex items-center gap-1 text-emerald-600">
<TrendingDown className="size-4" aria-hidden />
<span className="text-xs">rahatlıyor</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-slate-500">
<Minus className="size-4" aria-hidden />
<span className="text-xs">stabil</span>
</span>
);
}
function fmt(n: number | null): string {
return n == null ? "—" : n.toLocaleString("tr-TR");
}
function ProgramTable({ programs }: { programs: Program[] }) {
if (programs.length === 0) {
return (
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
Bu dilimde uygun program bulunamadı.
</p>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Program</TableHead>
<TableHead>Üniversite</TableHead>
<TableHead className="text-right">2025 taban sırası</TableHead>
<TableHead className="text-right">2024</TableHead>
<TableHead>Trend</TableHead>
<TableHead className="text-right">Kontenjan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programs.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
{p.isim}
{p.fakulte ? (
<span className="block text-xs text-slate-500">
{p.fakulte}
</span>
) : null}
</TableCell>
<TableCell>
{p.universite}
<span className="block text-xs text-slate-500">
{p.il}
{p.unitur ? ` · ${p.unitur.toLocaleLowerCase("tr-TR")}` : ""}
</span>
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{fmt(p.sira2025)}
</TableCell>
<TableCell className="text-right tabular-nums text-slate-500">
{fmt(p.sira2024)}
</TableCell>
<TableCell>
<TrendIcon p={p} />
</TableCell>
<TableCell className="text-right tabular-nums">
{fmt(p.kontenjan2025)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export default async function SonucPage({ export default async function SonucPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ searchParams: Promise<{
sira?: string; sira?: string;
tur?: string; tur?: string;
il?: string;
sihirbaz?: string; sihirbaz?: string;
}>; }>;
}) { }) {
@@ -161,7 +44,6 @@ export default async function SonucPage({
params.tur && params.tur in PUAN_TURLERI params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey) ? (params.tur as PuanTuruKey)
: "say"; : "say";
const seciliIl = params.il?.trim() || null;
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return ( return (
@@ -170,7 +52,7 @@ export default async function SonucPage({
Geçerli bir sıralama gerekli Geçerli bir sıralama gerekli
</h1> </h1>
<p className="mt-3 text-slate-600"> <p className="mt-3 text-slate-600">
Sonuçları görebilmek için ana sayfadan YKS başarı sıralamanı gir. Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.
</p> </p>
<Button asChild className="mt-6 cursor-pointer"> <Button asChild className="mt-6 cursor-pointer">
<Link href="/"> <Link href="/">
@@ -211,65 +93,12 @@ export default async function SonucPage({
il: p.il, il: p.il,
})); }));
const results = searchByRank(sira, turKey, {
il: seciliIl ?? undefined,
});
// Harita her zaman filtresiz dağılımı gösterir ki il değiştirilebilsin
const haritaSonuclari = seciliIl ? searchByRank(sira, turKey) : results;
// Harita için il bazında özet (81 il haritada; KKTC/yurtdışı ayrıca listelenir)
const iller: Record<string, IlOzeti> = {};
const dilimler = [
["hayal", haritaSonuclari.hayal],
["dengeli", haritaSonuclari.dengeli],
["garanti", haritaSonuclari.garanti],
] as const;
for (const [dilim, programs] of dilimler) {
for (const p of programs) {
if (!p.il) continue;
iller[p.il] ??= { hayal: 0, dengeli: 0, garanti: 0 };
iller[p.il][dilim]++;
}
}
const sections = [
{
key: "hayal",
icon: Rocket,
title: "Hayal",
tone: "text-orange-600",
description:
"Geçen yıl tabanı senin sıralamanın üzerinde kapanan programlar. Şansın düşük ama listenin başında birkaç tane bulunmalı.",
programs: results.hayal,
},
{
key: "dengeli",
icon: Scale,
title: "Dengeli",
tone: "text-primary",
description:
"Tabanı sıralamanın hemen civarında. Listenin bel kemiği bu dilimden kurulur.",
programs: results.dengeli,
},
{
key: "garanti",
icon: ShieldCheck,
title: "Garanti",
tone: "text-emerald-600",
description:
"Tabanı sıralamanın belirgin altında. Boşta kalmamak için listenin sonunda mutlaka yer almalı.",
programs: results.garanti,
},
] as const;
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <div className="min-h-screen bg-slate-50 text-slate-900">
<main <main className="mx-auto max-w-4xl px-4 py-12">
className={`mx-auto max-w-6xl px-4 py-12 ${mevcutRapor ? "" : "pb-56"}`}
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<Badge variant="secondary" className="bg-primary/10 text-primary"> <Badge variant="secondary" className="bg-primary/10 text-primary">
Ücretsiz ön izleme {sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
</Badge> </Badge>
<Button <Button
asChild asChild
@@ -284,12 +113,15 @@ export default async function SonucPage({
</Button> </Button>
</div> </div>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl"> <h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için görünüm {sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
</h1> </h1>
<p className="mt-2 max-w-2xl text-slate-600"> <p className="mt-2 max-w-2xl text-slate-600">
2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler. Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
noktasıdır. satır yerleşme riskine göre renklenir:{" "}
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
<span className="font-semibold text-red-600">kırmızı riskli</span>.
</p> </p>
{/* Puan türü seçimi */} {/* Puan türü seçimi */}
@@ -302,82 +134,15 @@ export default async function SonucPage({
variant={key === turKey ? "default" : "outline"} variant={key === turKey ? "default" : "outline"}
className="cursor-pointer" className="cursor-pointer"
> >
<Link <Link href={`/sonuc?sira=${sira}&tur=${key}`}>
href={`/sonuc?sira=${sira}&tur=${key}${
seciliIl ? `&il=${encodeURIComponent(seciliIl)}` : ""
}`}
>
{TUR_LABELS[key]} {TUR_LABELS[key]}
</Link> </Link>
</Button> </Button>
))} ))}
</nav> </nav>
{seciliIl ? ( {/* Sihirbaz akışı (CTA + modal) — seçimler ana sayfadaki modalda yapılır,
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3"> burada yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır */}
<MapPin className="size-4 text-primary" aria-hidden />
<p className="text-sm font-medium">
Yalnızca <span className="font-bold">{seciliIl}</span>{" "}
programları gösteriliyor
</p>
<Button
asChild
size="sm"
variant="outline"
className="cursor-pointer"
>
<Link href={`/sonuc?sira=${sira}&tur=${turKey}`}>
<X className="size-3.5" aria-hidden />
Filtreyi kaldır
</Link>
</Button>
</div>
) : null}
{/* Bölge haritası */}
<section aria-labelledby="baslik-harita" className="mt-10">
<h2 id="baslik-harita" className="font-heading text-2xl font-bold">
Nerede okuyacaksın?
</h2>
<p className="mt-1 text-sm text-slate-600">
Aşağıdaki önerilerin illere dağılımı. Koyu renk, o ilde daha fazla
öneri olduğunu gösterir. Bir ile tıklayarak listeyi o ile
daraltabilir, tekrar tıklayarak filtreyi kaldırabilirsin.
</p>
<div className="mt-4 rounded-2xl border border-slate-200 bg-white p-4 sm:p-6">
<BolgeHaritasi
iller={iller}
sira={sira}
turKey={turKey}
seciliIl={seciliIl}
/>
</div>
</section>
<div className="mt-10 space-y-12">
{sections.map((s) => (
<section key={s.key} aria-labelledby={`baslik-${s.key}`}>
<div className="mb-4 flex items-start gap-3">
<s.icon className={`mt-1 size-6 ${s.tone}`} aria-hidden />
<div>
<h2
id={`baslik-${s.key}`}
className="font-heading text-2xl font-bold"
>
{s.title}
<span className="ml-2 text-base font-normal text-slate-500">
{s.programs.length} program
</span>
</h2>
<p className="text-sm text-slate-600">{s.description}</p>
</div>
</div>
<ProgramTable programs={s.programs} />
</section>
))}
</div>
{/* Ücretli katman — sihirbaz akışı (CTA + alt kart + modal) */}
<SihirbazBolumu <SihirbazBolumu
sira={sira} sira={sira}
tur={turKey} tur={turKey}

View File

@@ -5,12 +5,10 @@ import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
ArrowLeft, ArrowLeft,
ArrowRight,
FileText, FileText,
Loader2, Loader2,
Lock, Lock,
RefreshCcw, RefreshCcw,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -22,16 +20,13 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi"; import { RaporListesi } from "@/components/rapor-listesi";
import { SoruKutusu } from "./soru-kutusu"; import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
import type { SihirbazFacetleri } from "@/lib/db"; import type { SihirbazFacetleri } from "@/lib/db";
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 { import {
ONCELIKLER,
SIHIRBAZ_STORAGE_KEY, SIHIRBAZ_STORAGE_KEY,
UNIVERSITE_TIPI_ETIKET,
type SihirbazSecimleri, type SihirbazSecimleri,
type UniversiteTipi,
} from "@/lib/sihirbaz"; } from "@/lib/sihirbaz";
import { listeOlustur, listeRevize } from "./actions"; import { listeOlustur, listeRevize } from "./actions";
@@ -52,31 +47,6 @@ export type MevcutRapor = {
type Faz = "adimlar" | "onizleme" | "uretiliyor" | "liste"; type Faz = "adimlar" | "onizleme" | "uretiliyor" | "liste";
function Cip({
secili,
onClick,
children,
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{children}
</button>
);
}
const URETIM_MESAJLARI = [ const URETIM_MESAJLARI = [
"Sıralamana uygun programlar taranıyor…", "Sıralamana uygun programlar taranıyor…",
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…", "Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
@@ -115,48 +85,19 @@ export function SihirbazModal({
const router = useRouter(); const router = useRouter();
const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar"); const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar");
const [adim, setAdim] = useState(0);
const [kredi, setKredi] = useState(baslangicKredi); const [kredi, setKredi] = useState(baslangicKredi);
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor); const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
const [uretimMesajIdx, setUretimMesajIdx] = useState(0); const [uretimMesajIdx, setUretimMesajIdx] = useState(0);
// Seçimler // Adımlarda yapılan son seçimler (önizlemeden geri dönüşte prefill için)
const [kategoriler, setKategoriler] = useState<string[]>( const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
baslangicSecimler?.kategoriler ?? [], baslangicSecimler ?? null,
);
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
baslangicSecimler?.universiteTipi ?? "farketmez",
);
const [oncelikler, setOncelikler] = useState<string[]>(
baslangicSecimler?.oncelikler ?? [],
); );
// 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);
function secimleriTopla(): SihirbazSecimleri {
return { kategoriler, iller, universiteTipi, oncelikler };
}
function listeDegistir(
liste: string[],
setListe: (v: string[]) => void,
deger: string,
maks?: number,
) {
if (liste.includes(deger)) {
setListe(liste.filter((x) => x !== deger));
} else {
if (maks && liste.length >= maks) {
toast.info(`En fazla ${maks} seçebilirsin.`);
return;
}
setListe([...liste, deger]);
}
}
async function uret(secimler: SihirbazSecimleri) { async function uret(secimler: SihirbazSecimleri) {
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID(); if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
setFaz("uretiliyor"); setFaz("uretiliyor");
@@ -241,8 +182,8 @@ export function SihirbazModal({
} catch {} } catch {}
} }
function ilerle() { function ilerle(secimler: SihirbazSecimleri) {
const secimler = secimleriTopla(); setSonSecimler(secimler);
if (!girisliMi) { if (!girisliMi) {
secimleriKaydetVeGirise(secimler); secimleriKaydetVeGirise(secimler);
return; return;
@@ -255,162 +196,17 @@ export function SihirbazModal({
void uret(secimler); void uret(secimler);
} }
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
// ---- Faz gövdeleri ---- // ---- Faz gövdeleri ----
const adimlarGovde = ( const adimlarGovde = (
<div className="flex flex-col gap-5"> <SihirbazAdimlar
<div className="flex items-center gap-1.5"> facetler={facetler}
{[0, 1, 2].map((i) => ( baslangicSecimler={sonSecimler}
<span sonButonEtiketi={
key={i} hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"
className={
i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-all"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all"
} }
onTamamla={ilerle}
/> />
))}
<span className="ml-2 text-xs font-medium text-slate-400">
Adım {adim + 1}/3
</span>
</div>
{adim === 0 ? (
<div>
<h3 className="font-heading text-lg font-bold">
Hangi alanlara ilgi duyuyorsun?
</h3>
<p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.kategoriler.map((k) => (
<Cip
key={k.ad}
secili={kategoriler.includes(k.ad)}
onClick={() =>
listeDegistir(kategoriler, setKategoriler, k.ad)
}
>
{k.ad}
<span className="ml-1.5 text-xs opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</div>
) : adim === 1 ? (
<div>
<h3 className="font-heading text-lg font-bold">
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
İstersen boş bırak (fark etmez). En fazla 5 il.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.iller.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
</div>
</div>
) : (
<div className="space-y-5">
<div>
<h3 className="font-heading text-lg font-bold">
Üniversite tipi & öncelikler
</h3>
<p className="mt-1 text-sm text-slate-500">
Bunlar listenin dengesini belirler.
</p>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Üniversite tipi
</p>
<div className="flex flex-wrap gap-2">
{facetler.uniturler.map((u) => (
<Cip
key={u.grup}
secili={universiteTipi === u.grup}
onClick={() =>
setUniversiteTipi(
universiteTipi === u.grup ? "farketmez" : u.grup,
)
}
>
{UNIVERSITE_TIPI_ETIKET[u.grup]}
<span className="ml-1.5 text-xs opacity-70">{u.adet}</span>
</Cip>
))}
<Cip
secili={universiteTipi === "farketmez"}
onClick={() => setUniversiteTipi("farketmez")}
>
Fark etmez
</Cip>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Senin için önemli olan
</p>
<div className="flex flex-wrap gap-2">
{ONCELIKLER.map((o) => (
<Cip
key={o}
secili={oncelikler.includes(o)}
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
>
{o}
</Cip>
))}
</div>
</div>
</div>
)}
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setAdim((a) => a - 1)}
disabled={adim === 0}
className="cursor-pointer"
>
<ArrowLeft className="size-4" aria-hidden />
Geri
</Button>
{adim < 2 ? (
<Button
type="button"
onClick={() => setAdim((a) => a + 1)}
disabled={!adimGecerli}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Devam
<ArrowRight className="size-4" aria-hidden />
</Button>
) : (
<Button
type="button"
onClick={ilerle}
disabled={kategoriler.length === 0}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"}
</Button>
)}
</div>
</div>
); );
const onizlemeGovde = ( const onizlemeGovde = (
@@ -517,7 +313,7 @@ export function SihirbazModal({
</div> </div>
<div className="mt-5"> <div className="mt-5">
<RaporListesi rapor={rapor.rapor} /> <RaporListesi rapor={rapor.rapor} adaySira={rapor.params.sira} />
</div> </div>
<div className="mt-6"> <div className="mt-6">
@@ -544,8 +340,22 @@ export function SihirbazModal({
/> />
</div> </div>
<div className="mt-6"> <div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4">
<SoruKutusu kredi={kredi} onKrediDegisti={setKredi} /> <div>
<p className="font-heading text-sm font-bold">
Listen hakkında soruların mı var?
</p>
<p className="text-xs text-slate-500">
AI danışman listeni ve seçimlerini bilir; her mesaj 1 kredi.
</p>
</div>
<Button
asChild
size="sm"
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/listem">Danışmana sor</Link>
</Button>
</div> </div>
</div> </div>
) : null; ) : null;

View File

@@ -2,26 +2,67 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react"; import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
import type { SihirbazFacetleri } from "@/lib/db";
import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz";
const TUR: string = "say";
export function HeroForm() { export function HeroForm() {
const [siralama, setSiralama] = useState(""); const [siralama, setSiralama] = useState("");
const [sira, setSira] = useState<number | null>(null);
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
const [yukleniyor, setYukleniyor] = useState(false);
const [modalAcik, setModalAcik] = useState(false);
const router = useRouter(); const router = useRouter();
function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const value = Number(siralama.replace(/\./g, "")); const value = Number(siralama.replace(/\./g, ""));
if (!value || value < 1 || value > 4_000_000) { if (!value || value < 1 || value > 4_000_000) {
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000)."); toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
return; return;
} }
router.push(`/sonuc?sira=${value}`); setYukleniyor(true);
try {
const res = await fetch(`/api/facetler?sira=${value}&tur=${TUR}`);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
setSira(value);
setFacetler(veri.facetler);
setModalAcik(true);
} catch {
toast.error("Bir şeyler ters gitti, tekrar dener misin?");
} finally {
setYukleniyor(false);
}
}
// Seçimler tamam: sonuç sayfası (ve sonrası) kullanabilsin diye sakla, yönlendir.
function secimleriTamamla(secimler: SihirbazSecimleri) {
if (!sira) return;
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur: TUR, secimler }),
);
} catch {}
setModalAcik(false);
router.push(`/sonuc?sira=${sira}&tur=${TUR}`);
} }
return ( return (
<>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex w-full max-w-md flex-col gap-3 sm:flex-row" className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
@@ -40,11 +81,33 @@ export function HeroForm() {
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
{yukleniyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Listemi oluştur Listemi oluştur
<ArrowRight className="size-4" aria-hidden /> {yukleniyor ? null : <ArrowRight className="size-4" aria-hidden />}
</Button> </Button>
</form> </form>
<Dialog open={modalAcik} onOpenChange={setModalAcik}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 text-left sm:max-w-3xl">
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
<DialogDescription className="sr-only">
Sıralamana uygun seçimlerini yap, tercih planına geç.
</DialogDescription>
{facetler ? (
<SihirbazAdimlar
key={sira}
facetler={facetler}
sonButonEtiketi="Tercih planıma geç"
onTamamla={secimleriTamamla}
/>
) : null}
</DialogContent>
</Dialog>
</>
); );
} }

View File

@@ -0,0 +1,239 @@
"use client";
// Sihirbazın 3 adımlık seçim gövdesi. Hem ana sayfadaki hero modalında hem de
// /sonuc'taki sihirbaz modalında kullanılır; seçim state'ini kendi tutar,
// tamamlanınca onTamamla(secimler) çağırır.
import { useState } from "react";
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type { SihirbazFacetleri } from "@/lib/db";
import {
ONCELIKLER,
UNIVERSITE_TIPI_ETIKET,
type SihirbazSecimleri,
type UniversiteTipi,
} from "@/lib/sihirbaz";
export function Cip({
secili,
onClick,
children,
}: {
secili: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={secili}
className={
secili
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200"
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
}
>
{children}
</button>
);
}
export function SihirbazAdimlar({
facetler,
baslangicSecimler,
sonButonEtiketi,
onTamamla,
}: {
facetler: SihirbazFacetleri;
baslangicSecimler?: SihirbazSecimleri | null;
sonButonEtiketi: string;
onTamamla: (secimler: SihirbazSecimleri) => void;
}) {
const [adim, setAdim] = useState(0);
const [kategoriler, setKategoriler] = useState<string[]>(
baslangicSecimler?.kategoriler ?? [],
);
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
baslangicSecimler?.universiteTipi ?? "farketmez",
);
const [oncelikler, setOncelikler] = useState<string[]>(
baslangicSecimler?.oncelikler ?? [],
);
function listeDegistir(
liste: string[],
setListe: (v: string[]) => void,
deger: string,
maks?: number,
) {
if (liste.includes(deger)) {
setListe(liste.filter((x) => x !== deger));
} else {
if (maks && liste.length >= maks) {
toast.info(`En fazla ${maks} seçebilirsin.`);
return;
}
setListe([...liste, deger]);
}
}
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
return (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-1.5">
{[0, 1, 2].map((i) => (
<span
key={i}
className={
i === adim
? "h-1.5 w-6 rounded-full bg-primary transition-all"
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all"
}
/>
))}
<span className="ml-2 text-xs font-medium text-slate-400">
Adım {adim + 1}/3
</span>
</div>
{adim === 0 ? (
<div>
<h3 className="font-heading text-lg font-bold">
Hangi alanlara ilgi duyuyorsun?
</h3>
<p className="mt-1 text-sm text-slate-500">
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.kategoriler.map((k) => (
<Cip
key={k.ad}
secili={kategoriler.includes(k.ad)}
onClick={() => listeDegistir(kategoriler, setKategoriler, k.ad)}
>
{k.ad}
<span className="ml-1.5 text-xs opacity-70">{k.adet}</span>
</Cip>
))}
</div>
</div>
) : adim === 1 ? (
<div>
<h3 className="font-heading text-lg font-bold">
Nerede okumak istersin?
</h3>
<p className="mt-1 text-sm text-slate-500">
İstersen boş bırak (fark etmez). En fazla 5 il.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{facetler.iller.map((i) => (
<Cip
key={i.il}
secili={iller.includes(i.il)}
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
>
{i.il}
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
</Cip>
))}
</div>
</div>
) : (
<div className="space-y-5">
<div>
<h3 className="font-heading text-lg font-bold">
Üniversite tipi & öncelikler
</h3>
<p className="mt-1 text-sm text-slate-500">
Bunlar listenin dengesini belirler.
</p>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Üniversite tipi
</p>
<div className="flex flex-wrap gap-2">
{facetler.uniturler.map((u) => (
<Cip
key={u.grup}
secili={universiteTipi === u.grup}
onClick={() =>
setUniversiteTipi(
universiteTipi === u.grup ? "farketmez" : u.grup,
)
}
>
{UNIVERSITE_TIPI_ETIKET[u.grup]}
<span className="ml-1.5 text-xs opacity-70">{u.adet}</span>
</Cip>
))}
<Cip
secili={universiteTipi === "farketmez"}
onClick={() => setUniversiteTipi("farketmez")}
>
Fark etmez
</Cip>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
Senin için önemli olan
</p>
<div className="flex flex-wrap gap-2">
{ONCELIKLER.map((o) => (
<Cip
key={o}
secili={oncelikler.includes(o)}
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
>
{o}
</Cip>
))}
</div>
</div>
</div>
)}
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<Button
type="button"
variant="ghost"
onClick={() => setAdim((a) => a - 1)}
disabled={adim === 0}
className="cursor-pointer"
>
<ArrowLeft className="size-4" aria-hidden />
Geri
</Button>
{adim < 2 ? (
<Button
type="button"
onClick={() => setAdim((a) => a + 1)}
disabled={!adimGecerli}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Devam
<ArrowRight className="size-4" aria-hidden />
</Button>
) : (
<Button
type="button"
onClick={() =>
onTamamla({ kategoriler, iller, universiteTipi, oncelikler })
}
disabled={kategoriler.length === 0}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{sonButonEtiketi}
</Button>
)}
</div>
</div>
);
}