1 Commits

Author SHA1 Message Date
bc77178ece Merge pull request 'main' (#2) from main into feat/kolaytercih-mvp
Reviewed-on: #2
2026-07-24 02:21:55 +03:00
18 changed files with 526 additions and 692 deletions

View File

@@ -1,15 +0,0 @@
node_modules
.next
.git
.agents
.claude
.mcp.json
*.md
.env*
!.env.example
drizzle/
data/
!data/app.db
!data/yokatlas.db
!data/yokatlas.db-wal
!data/yokatlas.db-shm

View File

@@ -1,35 +0,0 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Kaynak kodu çek
uses: actions/checkout@v4
- name: .env.production oluştur
run: |
cat > .env.production <<'EOF'
BETTER_AUTH_SECRET=${{ secrets.BETTER_AUTH_SECRET }}
BETTER_AUTH_URL=${{ secrets.BETTER_AUTH_URL }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
EMAIL_FROM=${{ secrets.EMAIL_FROM }}
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
EOF
- name: Build & deploy
run: |
docker compose -p kolaytercih up -d --build --remove-orphans
docker image prune -f

View File

@@ -2,8 +2,4 @@
# This is NOT the Next.js you know # This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
# Push öncesi build zorunlu
Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add <dir> HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->

View File

@@ -1,55 +0,0 @@
FROM node:20-alpine AS base
# pnpm kurulumu
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
# ── Bağımlılık katmanı ──
FROM base AS deps
WORKDIR /app
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
RUN apk add --no-cache python3 make g++
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ── Derleme katmanı ──
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli
RUN mkdir -p data && pnpm build
# ── Çalıştırma katmanı ──
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Bağımlılıklar
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
COPY data/ ./seed/
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
# SQLite veri dizini
RUN chmod +x /app/docker-entrypoint.sh && \
mkdir -p /app/data && \
chown -R nextjs:nodejs /app/data /app/seed
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["/app/docker-entrypoint.sh"]

View File

@@ -1,27 +0,0 @@
services:
app:
build: .
container_name: kolaytercih
restart: unless-stopped
volumes:
- app-data:/app/data
env_file:
- .env.production
environment:
- APP_DB_URL=file:/app/data/app.db
networks:
- traefik
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.kolaytercih.rule=Host(`kolaytercih.com`)
- traefik.http.routers.kolaytercih.entrypoints=websecure
- traefik.http.routers.kolaytercih.tls.certresolver=letsencrypt
- traefik.http.services.kolaytercih.loadbalancer.server.port=3000
networks:
traefik:
external: true
volumes:
app-data:

View File

@@ -1,7 +1,6 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"], serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
async redirects() { async redirects() {
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme). // Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).

View File

@@ -1,17 +0,0 @@
#!/bin/sh
set -e
# yokatlas.db salt-okunur referans verisi: her deploy'da imajdaki güncel kopya volume'e yazılır
cp -f /app/seed/yokatlas.db /app/data/
for ek in wal shm; do
if [ -f "/app/seed/yokatlas.db-$ek" ]; then
cp -f "/app/seed/yokatlas.db-$ek" /app/data/
fi
done
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) şema tohumu kopyalanır
if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then
cp /app/seed/app.db /app/data/
fi
exec node server.js

View File

@@ -1,22 +0,0 @@
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

@@ -22,7 +22,7 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "KolayTercih — Tercih Danışmanı", title: "KolayTercih — Cebindeki Tercih Danışmanı",
description: description:
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.", "YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.",
}; };

View File

@@ -151,11 +151,8 @@ export default function Home() {
<main className="flex-1"> <main className="flex-1">
{/* Hero */} {/* Hero */}
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24"> <section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24">
<Badge <Badge variant="secondary" className="mb-6 bg-primary/10 text-primary">
variant="secondary" 2026 YKS tercih dönemi ık
className="mb-6 bg-primary/10 text-primary"
>
2026 YKS tercih dönemi ıkk
</Badge> </Badge>
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl"> <h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
Dört yıllık geleceğini{" "} Dört yıllık geleceğini{" "}
@@ -166,10 +163,7 @@ export default function Home() {
danışmanın. İnsan danışmanın binlerce lira aldığı işi dengeli danışmanın. İnsan danışmanın binlerce lira aldığı işi dengeli
liste, risk analizi, soru-cevap onda bir fiyatına yapar. liste, risk analizi, soru-cevap onda bir fiyatına yapar.
</p> </p>
<div <div id="hero-form" className="mt-10 flex w-full scroll-mt-28 justify-center">
id="hero-form"
className="mt-10 flex w-full scroll-mt-28 justify-center"
>
<HeroForm /> <HeroForm />
</div> </div>
<p className="mt-4 text-sm text-slate-500"> <p className="mt-4 text-sm text-slate-500">

View File

@@ -13,9 +13,9 @@ export const metadata: Metadata = {
}; };
const DILIM_LABEL: Record<string, string> = { const DILIM_LABEL: Record<string, string> = {
hayal: "Hayal (Riskli)", hayal: "Hayal",
dengeli: "Dengeli (Az riskli)", dengeli: "Dengeli",
garanti: "Garanti (Güvenli)", garanti: "Garanti",
}; };
export default async function YazdirPage() { export default async function YazdirPage() {

View File

@@ -1,11 +1,22 @@
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ArrowLeft } from "lucide-react"; import {
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";
@@ -13,12 +24,24 @@ 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 { MevcutRapor, OnizlemeSatir } from "./sihirbaz-modal"; import type {
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: "Tercih Listeni Kur — KolayTercih", title: "Sıralamana Uygun Programlar — KolayTercih",
}; };
const TUR_LABELS: Record<PuanTuruKey, string> = { const TUR_LABELS: Record<PuanTuruKey, string> = {
@@ -29,12 +52,106 @@ 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;
}>; }>;
}) { }) {
@@ -44,6 +161,7 @@ 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 (
@@ -52,7 +170,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">
Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir. Sonuçları görebilmek 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="/">
@@ -93,12 +211,65 @@ 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 className="mx-auto max-w-4xl px-4 py-12"> <main
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">
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]} Ücretsiz ön izleme
</Badge> </Badge>
<Button <Button
asChild asChild
@@ -113,15 +284,12 @@ 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 tercih planı {sira.toLocaleString("tr-TR")}. sıradaki bir aday için görünüm
</h1> </h1>
<p className="mt-2 max-w-2xl text-slate-600"> <p className="mt-2 max-w-2xl text-slate-600">
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ 2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler.
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç
satır yerleşme riskine göre renklenir:{" "} noktasıdır.
<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 */}
@@ -134,15 +302,82 @@ export default async function SonucPage({
variant={key === turKey ? "default" : "outline"} variant={key === turKey ? "default" : "outline"}
className="cursor-pointer" className="cursor-pointer"
> >
<Link href={`/sonuc?sira=${sira}&tur=${key}`}> <Link
href={`/sonuc?sira=${sira}&tur=${key}${
seciliIl ? `&il=${encodeURIComponent(seciliIl)}` : ""
}`}
>
{TUR_LABELS[key]} {TUR_LABELS[key]}
</Link> </Link>
</Button> </Button>
))} ))}
</nav> </nav>
{/* Sihirbaz akışı (CTA + modal) — seçimler ana sayfadaki modalda yapılır, {seciliIl ? (
burada yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açı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">
<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,10 +5,12 @@ 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";
@@ -20,13 +22,16 @@ 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 { SihirbazAdimlar } from "@/components/sihirbaz-adimlar"; import { SoruKutusu } from "./soru-kutusu";
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";
@@ -47,6 +52,31 @@ 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…",
@@ -85,19 +115,48 @@ 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);
// Adımlarda yapılan son seçimler (önizlemeden geri dönüşte prefill için) // Seçimler
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>( const [kategoriler, setKategoriler] = useState<string[]>(
baslangicSecimler ?? null, baslangicSecimler?.kategoriler ?? [],
);
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");
@@ -182,8 +241,8 @@ export function SihirbazModal({
} catch {} } catch {}
} }
function ilerle(secimler: SihirbazSecimleri) { function ilerle() {
setSonSecimler(secimler); const secimler = secimleriTopla();
if (!girisliMi) { if (!girisliMi) {
secimleriKaydetVeGirise(secimler); secimleriKaydetVeGirise(secimler);
return; return;
@@ -196,17 +255,162 @@ 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 = (
<SihirbazAdimlar <div className="flex flex-col gap-5">
facetler={facetler} <div className="flex items-center gap-1.5">
baslangicSecimler={sonSecimler} {[0, 1, 2].map((i) => (
sonButonEtiketi={ <span
hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör" 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"
} }
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 = (
@@ -313,7 +517,7 @@ export function SihirbazModal({
</div> </div>
<div className="mt-5"> <div className="mt-5">
<RaporListesi rapor={rapor.rapor} adaySira={rapor.params.sira} /> <RaporListesi rapor={rapor.rapor} />
</div> </div>
<div className="mt-6"> <div className="mt-6">
@@ -340,22 +544,8 @@ export function SihirbazModal({
/> />
</div> </div>
<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"> <div className="mt-6">
<div> <SoruKutusu kredi={kredi} onKrediDegisti={setKredi} />
<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,67 +2,26 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react"; import { ArrowRight } 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();
async function handleSubmit(e: React.FormEvent) { 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;
} }
setYukleniyor(true); router.push(`/sonuc?sira=${value}`);
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"
@@ -81,33 +40,11 @@ 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
{yukleniyor ? null : <ArrowRight className="size-4" aria-hidden />} <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

@@ -1,54 +1,25 @@
import { Rocket, Scale, ShieldCheck } from "lucide-react"; import { Rocket, Scale, ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { RaporSonuc } from "@/lib/ai/rapor"; import type { RaporSonuc } from "@/lib/ai/rapor";
import {
RISK_ETIKET,
dilimdenRisk,
riskHesapla,
type RiskSeviyesi,
} from "@/lib/risk";
export const DILIM_ETIKET: Record<string, string> = { export const DILIM_STIL: Record<
hayal: "Hayal", string,
dengeli: "Dengeli", { label: string; cls: string; icon: typeof Rocket }
garanti: "Garanti",
};
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
export const RISK_STIL: Record<
RiskSeviyesi,
{ badge: string; kenar: string; icon: typeof Rocket }
> = { > = {
riskli: { hayal: { label: "Hayal", cls: "bg-orange-100 text-orange-700", icon: Rocket },
badge: "bg-red-100 text-red-700", dengeli: { label: "Dengeli", cls: "bg-blue-100 text-blue-700", icon: Scale },
kenar: "border-l-red-500", garanti: {
icon: Rocket, label: "Garanti",
}, cls: "bg-emerald-100 text-emerald-700",
"az-riskli": {
badge: "bg-amber-100 text-amber-700",
kenar: "border-l-amber-500",
icon: Scale,
},
guvenli: {
badge: "bg-emerald-100 text-emerald-700",
kenar: "border-l-emerald-500",
icon: ShieldCheck, icon: ShieldCheck,
}, },
}; };
/** /**
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları). * 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
* Hem sonuç modalında hem sohbet sayfasında kullanılır. Risk rengi * Hem sonuç modalında hem PDF/yazdır sayfasında kullanılır.
* adayın sıralamasına göre deterministik hesaplanır (adaySira verilirse);
* eski raporlarda dilimden türetilir.
*/ */
export function RaporListesi({ export function RaporListesi({ rapor }: { rapor: RaporSonuc }) {
rapor,
adaySira,
}: {
rapor: RaporSonuc;
adaySira?: number;
}) {
return ( return (
<div> <div>
<div className="rounded-2xl border border-slate-200 bg-white p-6"> <div className="rounded-2xl border border-slate-200 bg-white p-6">
@@ -58,36 +29,14 @@ export function RaporListesi({
</p> </p>
</div> </div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500"> <ol className="mt-6 space-y-3">
{(["guvenli", "az-riskli", "riskli"] as const).map((r) => (
<span key={r} className="inline-flex items-center gap-1.5">
<span
className={`size-2.5 rounded-full ${
r === "guvenli"
? "bg-emerald-500"
: r === "az-riskli"
? "bg-amber-500"
: "bg-red-500"
}`}
aria-hidden
/>
{RISK_ETIKET[r]}
</span>
))}
</div>
<ol className="mt-3 space-y-3">
{rapor.tercihler.map((t) => { {rapor.tercihler.map((t) => {
const p = rapor.programlar[t.programId]; const p = rapor.programlar[t.programId];
const risk = const stil = DILIM_STIL[t.dilim];
(adaySira != null
? riskHesapla(p?.efektifSira, adaySira)
: null) ?? dilimdenRisk(t.dilim);
const stil = RISK_STIL[risk];
return ( return (
<li <li
key={t.sira} key={t.sira}
className={`rounded-2xl border border-l-4 border-slate-200 bg-white p-5 ${stil.kenar}`} className="rounded-2xl border border-slate-200 bg-white p-5"
> >
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold"> <span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold">
@@ -98,14 +47,11 @@ export function RaporListesi({
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{p?.universite} · {p?.il ?? "—"} {p?.universite} · {p?.il ?? "—"}
{p?.unitur ? ` · ${p.unitur}` : ""} {p?.unitur ? ` · ${p.unitur}` : ""}
{p?.efektifSira != null
? ` · taban ~${p.efektifSira.toLocaleString("tr-TR")}.`
: ""}
</p> </p>
</div> </div>
<Badge variant="secondary" className={stil.badge}> <Badge variant="secondary" className={stil.cls}>
<stil.icon className="size-3" aria-hidden /> <stil.icon className="size-3" aria-hidden />
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]} {stil.label}
</Badge> </Badge>
</div> </div>
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3"> <dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">

View File

@@ -1,239 +0,0 @@
"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>
);
}

View File

@@ -9,7 +9,6 @@ import {
type AdayProgram, type AdayProgram,
} from "../rapor-havuzu"; } from "../rapor-havuzu";
import { secimOzeti } from "../sihirbaz"; import { secimOzeti } from "../sihirbaz";
import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk";
const TercihSchema = z.object({ const TercihSchema = z.object({
programId: z.string(), programId: z.string(),
@@ -27,16 +26,9 @@ const RaporSchema = z.object({
export type RaporSonuc = z.infer<typeof RaporSchema> & { export type RaporSonuc = z.infer<typeof RaporSchema> & {
// Render için havuzdan zenginleştirilmiş program bilgisi // Render için havuzdan zenginleştirilmiş program bilgisi
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
programlar: Record< programlar: Record<
string, string,
{ { isim: string; universite: string; il: string | null; unitur: string | null }
isim: string;
universite: string;
il: string | null;
unitur: string | null;
efektifSira?: number | null;
}
>; >;
}; };
@@ -170,25 +162,17 @@ export async function raporUret(
universite: p.universite, universite: p.universite,
il: p.il, il: p.il,
unitur: p.unitur, unitur: p.unitur,
efektifSira: p.sira2025 ?? p.sira2024,
}; };
} }
return { ...sonuc, programlar }; return { ...sonuc, programlar };
} }
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string { export function listeOzetiCikar(rapor: RaporSonuc): string {
return rapor.tercihler return rapor.tercihler
.map((t) => { .map(
const p = rapor.programlar[t.programId]; (t) =>
const risk = `${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"}${rapor.programlar[t.programId]?.universite ?? "?"}`,
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ?? )
dilimdenRisk(t.dilim);
const taban =
p?.efektifSira != null
? ` (taban sırası ~${p.efektifSira.toLocaleString("tr-TR")})`
: "";
return `${t.sira}. [${t.dilim} | ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"}${p?.universite ?? "?"}${taban}`;
})
.join("\n"); .join("\n");
} }

View File

@@ -1,37 +0,0 @@
import type { Dilim } from "./rapor-havuzu";
/**
* Satır bazlı yerleşme riski. Renk dili ürünün her yerinde aynı:
* yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
*/
export type RiskSeviyesi = "guvenli" | "az-riskli" | "riskli";
export const RISK_ETIKET: Record<RiskSeviyesi, string> = {
guvenli: "Güvenli",
"az-riskli": "Az riskli",
riskli: "Riskli",
};
/**
* Deterministik risk: programın efektif taban sıralaması (COALESCE 2025→2024)
* adayın sıralamasıyla kıyaslanır. Eşikler db.ts'teki dilim aralıklarıyla
* birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, 3×sira]).
*/
export function riskHesapla(
efektifSira: number | null | undefined,
adaySira: number,
): RiskSeviyesi | null {
if (efektifSira == null || !Number.isFinite(adaySira) || adaySira < 1) {
return null;
}
if (efektifSira > Math.round(adaySira * 1.4)) return "guvenli";
if (efektifSira >= adaySira) return "az-riskli";
return "riskli";
}
/** Eski raporlar için (efektifSira kaydedilmemiş): dilimden risk türet. */
export function dilimdenRisk(dilim: Dilim | string): RiskSeviyesi {
if (dilim === "garanti") return "guvenli";
if (dilim === "dengeli") return "az-riskli";
return "riskli";
}