Compare commits
21 Commits
feat/kolay
...
1d0328d8eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d0328d8eb | ||
|
|
d5235310d5 | ||
|
|
2c02e3d7e9 | ||
|
|
fc146b0e18 | ||
|
|
92adfad464 | ||
|
|
669ff9b43a | ||
|
|
486f99c897 | ||
|
|
6d8699005c | ||
|
|
43457df3a9 | ||
|
|
312c06abbc | ||
|
|
0aaf705be2 | ||
|
|
fb44349bf8 | ||
| d5e23c0ce9 | |||
|
|
2ce0d417dc | ||
| fef13ce7b8 | |||
| 17caaab5fb | |||
| f815d1e861 | |||
|
|
1105415284 | ||
|
|
03107841ac | ||
| 5ce68c7932 | |||
|
|
307e778d62 |
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
35
.gitea/workflows/ci.yaml
Normal 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
|
||||||
@@ -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
55
Dockerfile
Normal 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
27
docker-compose.yml
Normal 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:
|
||||||
@@ -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).
|
||||||
|
|||||||
17
scripts/docker-entrypoint.sh
Normal file
17
scripts/docker-entrypoint.sh
Normal 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
|
||||||
22
src/app/api/facetler/route.ts
Normal file
22
src/app/api/facetler/route.ts
Normal 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) });
|
||||||
|
}
|
||||||
@@ -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ığı.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 açık
|
variant="secondary"
|
||||||
|
className="mb-6 bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
2026 YKS tercih dönemi açı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">
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
onTamamla={ilerle}
|
||||||
? "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={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;
|
||||||
|
|||||||
@@ -2,49 +2,112 @@
|
|||||||
|
|
||||||
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
|
<>
|
||||||
onSubmit={handleSubmit}
|
<form
|
||||||
className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
|
onSubmit={handleSubmit}
|
||||||
>
|
className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
|
||||||
<label htmlFor="siralama" className="sr-only">
|
|
||||||
YKS başarı sıralaması
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="siralama"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="YKS başarı sıralaman (ör. 85.000)"
|
|
||||||
value={siralama}
|
|
||||||
onChange={(e) => setSiralama(e.target.value)}
|
|
||||||
className="h-12 flex-1 bg-white text-base"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
>
|
||||||
Listemi oluştur
|
<label htmlFor="siralama" className="sr-only">
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
YKS başarı sıralaması
|
||||||
</Button>
|
</label>
|
||||||
</form>
|
<Input
|
||||||
|
id="siralama"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="YKS başarı sıralaman (ör. 85.000)"
|
||||||
|
value={siralama}
|
||||||
|
onChange={(e) => setSiralama(e.target.value)}
|
||||||
|
className="h-12 flex-1 bg-white text-base"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="lg"
|
||||||
|
disabled={yukleniyor}
|
||||||
|
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
|
||||||
|
{yukleniyor ? null : <ArrowRight className="size-4" aria-hidden />}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
239
src/components/sihirbaz-adimlar.tsx
Normal file
239
src/components/sihirbaz-adimlar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
37
src/lib/risk.ts
Normal 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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user