21 Commits

Author SHA1 Message Date
bilalgursen
1d0328d8eb AGENTS.md: push öncesi commit'lenmiş hal üzerinde lokal build kuralı
All checks were successful
Deploy / deploy (push) Successful in 1m57s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 02:35:41 +03:00
bilalgursen
d5235310d5 Risk hesaplama katmanı: rapor listesi satırları adayın sırasına göre renklenir
sihirbaz-modal'ın kullandığı RaporListesi adaySira prop'u bu dosyalara
bağlıydı; önceki commit'te eksik kalınca CI build'i kırıldı.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 02:35:20 +03:00
bilalgursen
2c02e3d7e9 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>
2026-07-26 02:30:44 +03:00
bilalgursen
fc146b0e18 Docker: seed veritabanları + entrypoint (yokatlas.db imaja gömülür, açılışta volume'e kopyalanır)
All checks were successful
Deploy / deploy (push) Successful in 1m52s
- .dockerignore: data/ içindeki sqlite dosyalarına build context izni
- runner imajına data/ -> /app/seed kopyalanır
- entrypoint: yokatlas.db her açılışta tazelenir, app.db sadece ilk kurulumda tohumlanır

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:51:36 +03:00
bilalgursen
92adfad464 CI: .env.production'ı tekil secret'lardan oluştur
All checks were successful
Deploy / deploy (push) Successful in 1m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:46:57 +03:00
bilalgursen
669ff9b43a CI tetikle: ENV_PRODUCTION secret sonrası yeniden deploy
All checks were successful
Deploy / deploy (push) Successful in 11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:44:23 +03:00
bilalgursen
486f99c897 compose: traefik ağına bağlan, kolaytercih.com router label'ları ekle
All checks were successful
Deploy / deploy (push) Successful in 12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:30:21 +03:00
bilalgursen
6d8699005c Dockerfile: build sırasında data/ dizinini oluştur (libsql SQLITE_CANTOPEN düzeltmesi)
All checks were successful
Deploy / deploy (push) Successful in 3m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:18:49 +03:00
bilalgursen
43457df3a9 Dockerfile: better-sqlite3 derlemesi için python3/make/g++ ekle
Some checks failed
Deploy / deploy (push) Failing after 6m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:08:03 +03:00
bilalgursen
312c06abbc Dockerfile: pnpm sürümünü 10.8.1'e sabitle (pnpm 11 Node 22 istiyor)
Some checks failed
Deploy / deploy (push) Failing after 50s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:06:35 +03:00
bilalgursen
0aaf705be2 CI: docker CLI içeren job imajı kullan (catthehacker/ubuntu:act-latest)
Some checks failed
Deploy / deploy (push) Failing after 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:05:06 +03:00
bilalgursen
fb44349bf8 Gitea CI'yı sıfırdan yaz: tek job ile compose build & deploy
Some checks failed
Deploy / deploy (push) Failing after 10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 01:04:13 +03:00
d5e23c0ce9 .gitea/workflows/ci.yml Sil
All checks were successful
Test / test (push) Successful in 2s
2026-07-25 18:49:21 +03:00
Muhammed Emin Akalan
2ce0d417dc Docker & Gitea CI ekle
Some checks failed
Test / test (push) Successful in 2s
CI / lint-and-build (push) Failing after 1m16s
CI / docker (push) Has been skipped
- Multi-stage Dockerfile (node:20-alpine, pnpm, standalone)
- docker-compose.yml (SQLite volume dahil)
- .dockerignore
- Gitea CI: lint + build + Docker imaj push
- next.config.ts: output standalone eklendi
2026-07-24 04:08:22 +03:00
fef13ce7b8 .gitea/workflows/ci.yaml Güncelle
All checks were successful
Test / test (push) Successful in 2s
2026-07-24 04:00:07 +03:00
17caaab5fb .gitea/workflows/ci.yaml Güncelle
Some checks failed
Test / test (push) Has been cancelled
2026-07-24 03:58:20 +03:00
f815d1e861 .gitea/workflows/ci.yaml Güncelle
Some checks failed
Test Runner / test-job (push) Has been cancelled
2026-07-24 03:52:38 +03:00
bilalgursen
1105415284 fix: correct typo in YKS preference period message
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 03:33:17 +03:00
Muhammed Emin Akalan
03107841ac fix: CI workflow — pnpm yerine bun + gerekli env vars
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 03:29:13 +03:00
5ce68c7932 src/app/layout.tsx Güncelle
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 03:26:12 +03:00
bilalgursen
307e778d62 feat: add CI workflow configuration for automated builds and linting
Some checks failed
CI / build (push) Has been cancelled
2026-07-24 02:56:04 +03:00
18 changed files with 692 additions and 526 deletions

15
.dockerignore Normal file
View File

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

35
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,35 @@
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,4 +2,8 @@
# 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 -->

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
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"]

27
docker-compose.yml Normal file
View File

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

@@ -0,0 +1,17 @@
#!/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

@@ -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

@@ -22,7 +22,7 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "KolayTercih — Cebindeki Tercih Danışmanı", title: "KolayTercih — 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,8 +151,11 @@ 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 variant="secondary" className="mb-6 bg-primary/10 text-primary"> <Badge
2026 YKS tercih dönemi ık variant="secondary"
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{" "}
@@ -163,7 +166,10 @@ 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 id="hero-form" className="mt-10 flex w-full scroll-mt-28 justify-center"> <div
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", hayal: "Hayal (Riskli)",
dengeli: "Dengeli", dengeli: "Dengeli (Az riskli)",
garanti: "Garanti", garanti: "Garanti (Güvenli)",
}; };
export default async function YazdirPage() { export default async function YazdirPage() {

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

@@ -1,25 +1,54 @@
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_STIL: Record< export const DILIM_ETIKET: Record<string, string> = {
string, hayal: "Hayal",
{ label: string; cls: string; icon: typeof Rocket } dengeli: "Dengeli",
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 }
> = { > = {
hayal: { label: "Hayal", cls: "bg-orange-100 text-orange-700", icon: Rocket }, riskli: {
dengeli: { label: "Dengeli", cls: "bg-blue-100 text-blue-700", icon: Scale }, badge: "bg-red-100 text-red-700",
garanti: { kenar: "border-l-red-500",
label: "Garanti", icon: Rocket,
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 PDF/yazdır sayfasında kullanılır. * Hem sonuç modalında hem sohbet sayfasında kullanılır. Risk rengi
* adayın sıralamasına göre deterministik hesaplanır (adaySira verilirse);
* eski raporlarda dilimden türetilir.
*/ */
export function RaporListesi({ rapor }: { rapor: RaporSonuc }) { export function RaporListesi({
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">
@@ -29,14 +58,36 @@ export function RaporListesi({ rapor }: { rapor: RaporSonuc }) {
</p> </p>
</div> </div>
<ol className="mt-6 space-y-3"> <div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500">
{(["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 stil = DILIM_STIL[t.dilim]; const risk =
(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-slate-200 bg-white p-5" className={`rounded-2xl border border-l-4 border-slate-200 bg-white p-5 ${stil.kenar}`}
> >
<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">
@@ -47,11 +98,14 @@ export function RaporListesi({ rapor }: { rapor: RaporSonuc }) {
<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.cls}> <Badge variant="secondary" className={stil.badge}>
<stil.icon className="size-3" aria-hidden /> <stil.icon className="size-3" aria-hidden />
{stil.label} {DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
</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

@@ -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>
);
}

View File

@@ -9,6 +9,7 @@ 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(),
@@ -26,9 +27,16 @@ 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;
}
>; >;
}; };
@@ -162,17 +170,25 @@ 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): string { export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
return rapor.tercihler return rapor.tercihler
.map( .map((t) => {
(t) => const p = rapor.programlar[t.programId];
`${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"}${rapor.programlar[t.programId]?.universite ?? "?"}`, const risk =
) (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");
} }

37
src/lib/risk.ts Normal file
View File

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