Compare commits
1 Commits
1d0328d8eb
...
feat/kolay
| Author | SHA1 | Date | |
|---|---|---|---|
| bc77178ece |
@@ -1,15 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
.git
|
|
||||||
.agents
|
|
||||||
.claude
|
|
||||||
.mcp.json
|
|
||||||
*.md
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
drizzle/
|
|
||||||
data/
|
|
||||||
!data/app.db
|
|
||||||
!data/yokatlas.db
|
|
||||||
!data/yokatlas.db-wal
|
|
||||||
!data/yokatlas.db-shm
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
name: Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Kaynak kodu çek
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: .env.production oluştur
|
|
||||||
run: |
|
|
||||||
cat > .env.production <<'EOF'
|
|
||||||
BETTER_AUTH_SECRET=${{ secrets.BETTER_AUTH_SECRET }}
|
|
||||||
BETTER_AUTH_URL=${{ secrets.BETTER_AUTH_URL }}
|
|
||||||
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL }}
|
|
||||||
GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}
|
|
||||||
GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }}
|
|
||||||
RESEND_API_KEY=${{ secrets.RESEND_API_KEY }}
|
|
||||||
EMAIL_FROM=${{ secrets.EMAIL_FROM }}
|
|
||||||
IYZICO_API_KEY=${{ secrets.IYZICO_API_KEY }}
|
|
||||||
IYZICO_SECRET_KEY=${{ secrets.IYZICO_SECRET_KEY }}
|
|
||||||
IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }}
|
|
||||||
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Build & deploy
|
|
||||||
run: |
|
|
||||||
docker compose -p kolaytercih up -d --build --remove-orphans
|
|
||||||
docker image prune -f
|
|
||||||
@@ -2,8 +2,4 @@
|
|||||||
# This is NOT the Next.js you know
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
# Push öncesi build zorunlu
|
|
||||||
|
|
||||||
Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden asla push etme. Kritik detay: çalışma ağacı kirliyken build, commit'lenmemiş dosyaları da gördüğü için CI'da kırılacak bir commit'i yakalayamayabilir. Bu yüzden build'i **push edilecek commit'in kendisi** üzerinde doğrula (ör. geçici `git worktree add <dir> HEAD` + `node_modules` symlink'i ile). Ayrıca kısmi commit atarken değişikliğin bağımlılık kapanışını kontrol et — bir dosyanın kullandığı prop/export başka bir commit'lenmemiş dosyadan geliyorsa onları da aynı commit'e dahil et.
|
|
||||||
<!-- END:nextjs-agent-rules -->
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
55
Dockerfile
55
Dockerfile
@@ -1,55 +0,0 @@
|
|||||||
FROM node:20-alpine AS base
|
|
||||||
|
|
||||||
# pnpm kurulumu
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.8.1 --activate
|
|
||||||
|
|
||||||
# ── Bağımlılık katmanı ──
|
|
||||||
FROM base AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
# better-sqlite3 Alpine'de kaynaktan derleniyor (musl için prebuilt yok)
|
|
||||||
RUN apk add --no-cache python3 make g++
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# ── Derleme katmanı ──
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
# data/ .dockerignore'da; libsql build sırasında file:./data/app.db açabilsin diye boş dizin gerekli
|
|
||||||
RUN mkdir -p data && pnpm build
|
|
||||||
|
|
||||||
# ── Çalıştırma katmanı ──
|
|
||||||
FROM node:20-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
|
||||||
adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
# Bağımlılıklar
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
|
|
||||||
# Seed veritabanları: entrypoint açılışta bunları /app/data volume'üne kopyalar
|
|
||||||
COPY data/ ./seed/
|
|
||||||
COPY scripts/docker-entrypoint.sh ./docker-entrypoint.sh
|
|
||||||
|
|
||||||
# SQLite veri dizini
|
|
||||||
RUN chmod +x /app/docker-entrypoint.sh && \
|
|
||||||
mkdir -p /app/data && \
|
|
||||||
chown -R nextjs:nodejs /app/data /app/seed
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
CMD ["/app/docker-entrypoint.sh"]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
container_name: kolaytercih
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- app-data:/app/data
|
|
||||||
env_file:
|
|
||||||
- .env.production
|
|
||||||
environment:
|
|
||||||
- APP_DB_URL=file:/app/data/app.db
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik
|
|
||||||
- traefik.http.routers.kolaytercih.rule=Host(`kolaytercih.com`)
|
|
||||||
- traefik.http.routers.kolaytercih.entrypoints=websecure
|
|
||||||
- traefik.http.routers.kolaytercih.tls.certresolver=letsencrypt
|
|
||||||
- traefik.http.services.kolaytercih.loadbalancer.server.port=3000
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
app-data:
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
|
||||||
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
|
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
|
||||||
async redirects() {
|
async redirects() {
|
||||||
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
// Eski akış /sonuc modalına taşındı. /rapor/yazdir korunur (tam eşleşme).
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# yokatlas.db salt-okunur referans verisi: her deploy'da imajdaki güncel kopya volume'e yazılır
|
|
||||||
cp -f /app/seed/yokatlas.db /app/data/
|
|
||||||
for ek in wal shm; do
|
|
||||||
if [ -f "/app/seed/yokatlas.db-$ek" ]; then
|
|
||||||
cp -f "/app/seed/yokatlas.db-$ek" /app/data/
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) şema tohumu kopyalanır
|
|
||||||
if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then
|
|
||||||
cp /app/seed/app.db /app/data/
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec node server.js
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
import { PUAN_TURLERI, rankWindowFacets, type PuanTuruKey } from "@/lib/db";
|
|
||||||
|
|
||||||
// Ana sayfadaki sihirbaz modalı için: sıralamaya uygun kategori/il/üniversite
|
|
||||||
// tipi facet'leri. /sonuc bu veriyi server component'te doğrudan alır; burada
|
|
||||||
// client'tan sıra girildikten sonra çekilir.
|
|
||||||
export function GET(request: NextRequest) {
|
|
||||||
const { searchParams } = request.nextUrl;
|
|
||||||
const sira = Number.parseInt(searchParams.get("sira") ?? "", 10);
|
|
||||||
const turParam = searchParams.get("tur") ?? "say";
|
|
||||||
const tur: PuanTuruKey =
|
|
||||||
turParam in PUAN_TURLERI ? (turParam as PuanTuruKey) : "say";
|
|
||||||
|
|
||||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Geçerli bir sıralama gerekli." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ facetler: rankWindowFacets(sira, tur) });
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "KolayTercih — Tercih Danışmanı",
|
title: "KolayTercih — Cebindeki Tercih Danışmanı",
|
||||||
description:
|
description:
|
||||||
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.",
|
"YKS sıralamana göre gerçek YÖK Atlas verisiyle dengeli 24 tercihlik liste. İnsan danışmanın onda bir fiyatına, yapay zekâ destekli tercih danışmanlığı.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -151,11 +151,8 @@ export default function Home() {
|
|||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24">
|
<section className="mx-auto flex max-w-6xl flex-col items-center px-4 pb-20 pt-16 text-center sm:pt-24">
|
||||||
<Badge
|
<Badge variant="secondary" className="mb-6 bg-primary/10 text-primary">
|
||||||
variant="secondary"
|
2026 YKS tercih dönemi açık
|
||||||
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{" "}
|
||||||
@@ -166,10 +163,7 @@ export default function Home() {
|
|||||||
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
|
danışmanın. İnsan danışmanın binlerce lira aldığı işi — dengeli
|
||||||
liste, risk analizi, soru-cevap — onda bir fiyatına yapar.
|
liste, risk analizi, soru-cevap — onda bir fiyatına yapar.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div id="hero-form" className="mt-10 flex w-full scroll-mt-28 justify-center">
|
||||||
id="hero-form"
|
|
||||||
className="mt-10 flex w-full scroll-mt-28 justify-center"
|
|
||||||
>
|
|
||||||
<HeroForm />
|
<HeroForm />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm text-slate-500">
|
<p className="mt-4 text-sm text-slate-500">
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DILIM_LABEL: Record<string, string> = {
|
const DILIM_LABEL: Record<string, string> = {
|
||||||
hayal: "Hayal (Riskli)",
|
hayal: "Hayal",
|
||||||
dengeli: "Dengeli (Az riskli)",
|
dengeli: "Dengeli",
|
||||||
garanti: "Garanti (Güvenli)",
|
garanti: "Garanti",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function YazdirPage() {
|
export default async function YazdirPage() {
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
MapPin,
|
||||||
|
Minus,
|
||||||
|
Rocket,
|
||||||
|
Scale,
|
||||||
|
ShieldCheck,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
PUAN_TURLERI,
|
PUAN_TURLERI,
|
||||||
searchByRank,
|
searchByRank,
|
||||||
rankWindowFacets,
|
rankWindowFacets,
|
||||||
|
type Program,
|
||||||
type PuanTuruKey,
|
type PuanTuruKey,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { getSession, getCurrentUser } from "@/lib/session";
|
import { getSession, getCurrentUser } from "@/lib/session";
|
||||||
@@ -13,12 +24,24 @@ import { appDb, schema } from "@/lib/appdb";
|
|||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
|
||||||
import type { MevcutRapor, OnizlemeSatir } from "./sihirbaz-modal";
|
import type {
|
||||||
|
MevcutRapor,
|
||||||
|
OnizlemeSatir,
|
||||||
|
} from "./sihirbaz-modal";
|
||||||
|
import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Listeni Kur — KolayTercih",
|
title: "Sıralamana Uygun Programlar — KolayTercih",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
const TUR_LABELS: Record<PuanTuruKey, string> = {
|
||||||
@@ -29,12 +52,106 @@ const TUR_LABELS: Record<PuanTuruKey, string> = {
|
|||||||
tyt: "TYT (Önlisans)",
|
tyt: "TYT (Önlisans)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function TrendIcon({ p }: { p: Program }) {
|
||||||
|
if (p.sira2025 == null || p.sira2024 == null) {
|
||||||
|
return <Minus className="size-4 text-slate-400" aria-label="Veri yok" />;
|
||||||
|
}
|
||||||
|
const degisim = (p.sira2024 - p.sira2025) / p.sira2024;
|
||||||
|
// taban sıralaması küçülüyorsa bölüm zorlaşıyor demektir
|
||||||
|
if (degisim > 0.05) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-orange-600">
|
||||||
|
<TrendingUp className="size-4" aria-hidden />
|
||||||
|
<span className="text-xs">zorlaşıyor</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (degisim < -0.05) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||||
|
<TrendingDown className="size-4" aria-hidden />
|
||||||
|
<span className="text-xs">rahatlıyor</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-slate-500">
|
||||||
|
<Minus className="size-4" aria-hidden />
|
||||||
|
<span className="text-xs">stabil</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number | null): string {
|
||||||
|
return n == null ? "—" : n.toLocaleString("tr-TR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgramTable({ programs }: { programs: Program[] }) {
|
||||||
|
if (programs.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-xl border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||||
|
Bu dilimde uygun program bulunamadı.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-50">
|
||||||
|
<TableHead>Program</TableHead>
|
||||||
|
<TableHead>Üniversite</TableHead>
|
||||||
|
<TableHead className="text-right">2025 taban sırası</TableHead>
|
||||||
|
<TableHead className="text-right">2024</TableHead>
|
||||||
|
<TableHead>Trend</TableHead>
|
||||||
|
<TableHead className="text-right">Kontenjan</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{programs.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{p.isim}
|
||||||
|
{p.fakulte ? (
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
{p.fakulte}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{p.universite}
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
{p.il}
|
||||||
|
{p.unitur ? ` · ${p.unitur.toLocaleLowerCase("tr-TR")}` : ""}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium tabular-nums">
|
||||||
|
{fmt(p.sira2025)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums text-slate-500">
|
||||||
|
{fmt(p.sira2024)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TrendIcon p={p} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{fmt(p.kontenjan2025)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SonucPage({
|
export default async function SonucPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
sira?: string;
|
sira?: string;
|
||||||
tur?: string;
|
tur?: string;
|
||||||
|
il?: string;
|
||||||
sihirbaz?: string;
|
sihirbaz?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
@@ -44,6 +161,7 @@ export default async function SonucPage({
|
|||||||
params.tur && params.tur in PUAN_TURLERI
|
params.tur && params.tur in PUAN_TURLERI
|
||||||
? (params.tur as PuanTuruKey)
|
? (params.tur as PuanTuruKey)
|
||||||
: "say";
|
: "say";
|
||||||
|
const seciliIl = params.il?.trim() || null;
|
||||||
|
|
||||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +170,7 @@ export default async function SonucPage({
|
|||||||
Geçerli bir sıralama gerekli
|
Geçerli bir sıralama gerekli
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-slate-600">
|
<p className="mt-3 text-slate-600">
|
||||||
Listeni kurabilmek için ana sayfadan YKS başarı sıralamanı gir.
|
Sonuçları görebilmek için ana sayfadan YKS başarı sıralamanı gir.
|
||||||
</p>
|
</p>
|
||||||
<Button asChild className="mt-6 cursor-pointer">
|
<Button asChild className="mt-6 cursor-pointer">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
@@ -93,12 +211,65 @@ export default async function SonucPage({
|
|||||||
il: p.il,
|
il: p.il,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const results = searchByRank(sira, turKey, {
|
||||||
|
il: seciliIl ?? undefined,
|
||||||
|
});
|
||||||
|
// Harita her zaman filtresiz dağılımı gösterir ki il değiştirilebilsin
|
||||||
|
const haritaSonuclari = seciliIl ? searchByRank(sira, turKey) : results;
|
||||||
|
|
||||||
|
// Harita için il bazında özet (81 il haritada; KKTC/yurtdışı ayrıca listelenir)
|
||||||
|
const iller: Record<string, IlOzeti> = {};
|
||||||
|
const dilimler = [
|
||||||
|
["hayal", haritaSonuclari.hayal],
|
||||||
|
["dengeli", haritaSonuclari.dengeli],
|
||||||
|
["garanti", haritaSonuclari.garanti],
|
||||||
|
] as const;
|
||||||
|
for (const [dilim, programs] of dilimler) {
|
||||||
|
for (const p of programs) {
|
||||||
|
if (!p.il) continue;
|
||||||
|
iller[p.il] ??= { hayal: 0, dengeli: 0, garanti: 0 };
|
||||||
|
iller[p.il][dilim]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
key: "hayal",
|
||||||
|
icon: Rocket,
|
||||||
|
title: "Hayal",
|
||||||
|
tone: "text-orange-600",
|
||||||
|
description:
|
||||||
|
"Geçen yıl tabanı senin sıralamanın üzerinde kapanan programlar. Şansın düşük ama listenin başında birkaç tane bulunmalı.",
|
||||||
|
programs: results.hayal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "dengeli",
|
||||||
|
icon: Scale,
|
||||||
|
title: "Dengeli",
|
||||||
|
tone: "text-primary",
|
||||||
|
description:
|
||||||
|
"Tabanı sıralamanın hemen civarında. Listenin bel kemiği bu dilimden kurulur.",
|
||||||
|
programs: results.dengeli,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "garanti",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
title: "Garanti",
|
||||||
|
tone: "text-emerald-600",
|
||||||
|
description:
|
||||||
|
"Tabanı sıralamanın belirgin altında. Boşta kalmamak için listenin sonunda mutlaka yer almalı.",
|
||||||
|
programs: results.garanti,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
<main className="mx-auto max-w-4xl px-4 py-12">
|
<main
|
||||||
|
className={`mx-auto max-w-6xl px-4 py-12 ${mevcutRapor ? "" : "pb-56"}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
<Badge variant="secondary" className="bg-primary/10 text-primary">
|
||||||
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
|
Ücretsiz ön izleme
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
@@ -113,15 +284,12 @@ export default async function SonucPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||||
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için tercih planı
|
{sira.toLocaleString("tr-TR")}. sıradaki bir aday için görünüm
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 max-w-2xl text-slate-600">
|
<p className="mt-2 max-w-2xl text-slate-600">
|
||||||
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
|
2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler.
|
||||||
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
|
Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç
|
||||||
satır yerleşme riskine göre renklenir:{" "}
|
noktasıdır.
|
||||||
<span className="font-semibold text-emerald-600">yeşil güvenli</span>,{" "}
|
|
||||||
<span className="font-semibold text-amber-600">sarı az riskli</span>,{" "}
|
|
||||||
<span className="font-semibold text-red-600">kırmızı riskli</span>.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Puan türü seçimi */}
|
{/* Puan türü seçimi */}
|
||||||
@@ -134,15 +302,82 @@ export default async function SonucPage({
|
|||||||
variant={key === turKey ? "default" : "outline"}
|
variant={key === turKey ? "default" : "outline"}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<Link href={`/sonuc?sira=${sira}&tur=${key}`}>
|
<Link
|
||||||
|
href={`/sonuc?sira=${sira}&tur=${key}${
|
||||||
|
seciliIl ? `&il=${encodeURIComponent(seciliIl)}` : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{TUR_LABELS[key]}
|
{TUR_LABELS[key]}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Sihirbaz akışı (CTA + modal) — seçimler ana sayfadaki modalda yapılır,
|
{seciliIl ? (
|
||||||
burada yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır */}
|
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
|
||||||
|
<MapPin className="size-4 text-primary" aria-hidden />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Yalnızca <span className="font-bold">{seciliIl}</span>{" "}
|
||||||
|
programları gösteriliyor
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Link href={`/sonuc?sira=${sira}&tur=${turKey}`}>
|
||||||
|
<X className="size-3.5" aria-hidden />
|
||||||
|
Filtreyi kaldır
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Bölge haritası */}
|
||||||
|
<section aria-labelledby="baslik-harita" className="mt-10">
|
||||||
|
<h2 id="baslik-harita" className="font-heading text-2xl font-bold">
|
||||||
|
Nerede okuyacaksın?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Aşağıdaki önerilerin illere dağılımı. Koyu renk, o ilde daha fazla
|
||||||
|
öneri olduğunu gösterir. Bir ile tıklayarak listeyi o ile
|
||||||
|
daraltabilir, tekrar tıklayarak filtreyi kaldırabilirsin.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-white p-4 sm:p-6">
|
||||||
|
<BolgeHaritasi
|
||||||
|
iller={iller}
|
||||||
|
sira={sira}
|
||||||
|
turKey={turKey}
|
||||||
|
seciliIl={seciliIl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-10 space-y-12">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<section key={s.key} aria-labelledby={`baslik-${s.key}`}>
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<s.icon className={`mt-1 size-6 ${s.tone}`} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
id={`baslik-${s.key}`}
|
||||||
|
className="font-heading text-2xl font-bold"
|
||||||
|
>
|
||||||
|
{s.title}
|
||||||
|
<span className="ml-2 text-base font-normal text-slate-500">
|
||||||
|
{s.programs.length} program
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-600">{s.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProgramTable programs={s.programs} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ücretli katman — sihirbaz akışı (CTA + alt kart + modal) */}
|
||||||
<SihirbazBolumu
|
<SihirbazBolumu
|
||||||
sira={sira}
|
sira={sira}
|
||||||
tur={turKey}
|
tur={turKey}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { useRouter } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,13 +22,16 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { RaporListesi } from "@/components/rapor-listesi";
|
import { RaporListesi } from "@/components/rapor-listesi";
|
||||||
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
|
import { SoruKutusu } from "./soru-kutusu";
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
import type { SihirbazFacetleri } from "@/lib/db";
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import type { RaporParams } from "@/lib/rapor-havuzu";
|
import type { RaporParams } from "@/lib/rapor-havuzu";
|
||||||
import {
|
import {
|
||||||
|
ONCELIKLER,
|
||||||
SIHIRBAZ_STORAGE_KEY,
|
SIHIRBAZ_STORAGE_KEY,
|
||||||
|
UNIVERSITE_TIPI_ETIKET,
|
||||||
type SihirbazSecimleri,
|
type SihirbazSecimleri,
|
||||||
|
type UniversiteTipi,
|
||||||
} from "@/lib/sihirbaz";
|
} from "@/lib/sihirbaz";
|
||||||
import { listeOlustur, listeRevize } from "./actions";
|
import { listeOlustur, listeRevize } from "./actions";
|
||||||
|
|
||||||
@@ -47,6 +52,31 @@ export type MevcutRapor = {
|
|||||||
|
|
||||||
type Faz = "adimlar" | "onizleme" | "uretiliyor" | "liste";
|
type Faz = "adimlar" | "onizleme" | "uretiliyor" | "liste";
|
||||||
|
|
||||||
|
function Cip({
|
||||||
|
secili,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
secili: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={secili}
|
||||||
|
className={
|
||||||
|
secili
|
||||||
|
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200"
|
||||||
|
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const URETIM_MESAJLARI = [
|
const URETIM_MESAJLARI = [
|
||||||
"Sıralamana uygun programlar taranıyor…",
|
"Sıralamana uygun programlar taranıyor…",
|
||||||
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
"Gerçek YÖK Atlas verisiyle dengeli liste kuruluyor…",
|
||||||
@@ -85,19 +115,48 @@ export function SihirbazModal({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar");
|
const [faz, setFaz] = useState<Faz>(mevcutRapor ? "liste" : "adimlar");
|
||||||
|
const [adim, setAdim] = useState(0);
|
||||||
const [kredi, setKredi] = useState(baslangicKredi);
|
const [kredi, setKredi] = useState(baslangicKredi);
|
||||||
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
|
const [rapor, setRapor] = useState<MevcutRapor | null>(mevcutRapor);
|
||||||
const [uretimMesajIdx, setUretimMesajIdx] = useState(0);
|
const [uretimMesajIdx, setUretimMesajIdx] = useState(0);
|
||||||
|
|
||||||
// Adımlarda yapılan son seçimler (önizlemeden geri dönüşte prefill için)
|
// Seçimler
|
||||||
const [sonSecimler, setSonSecimler] = useState<SihirbazSecimleri | null>(
|
const [kategoriler, setKategoriler] = useState<string[]>(
|
||||||
baslangicSecimler ?? null,
|
baslangicSecimler?.kategoriler ?? [],
|
||||||
|
);
|
||||||
|
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
|
||||||
|
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
|
||||||
|
baslangicSecimler?.universiteTipi ?? "farketmez",
|
||||||
|
);
|
||||||
|
const [oncelikler, setOncelikler] = useState<string[]>(
|
||||||
|
baslangicSecimler?.oncelikler ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
|
// Aynı üretim için sabit requestId (retry çifte harcama yapmaz)
|
||||||
const requestIdRef = useRef<string | null>(null);
|
const requestIdRef = useRef<string | null>(null);
|
||||||
const otomatikBasladi = useRef(false);
|
const otomatikBasladi = useRef(false);
|
||||||
|
|
||||||
|
function secimleriTopla(): SihirbazSecimleri {
|
||||||
|
return { kategoriler, iller, universiteTipi, oncelikler };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listeDegistir(
|
||||||
|
liste: string[],
|
||||||
|
setListe: (v: string[]) => void,
|
||||||
|
deger: string,
|
||||||
|
maks?: number,
|
||||||
|
) {
|
||||||
|
if (liste.includes(deger)) {
|
||||||
|
setListe(liste.filter((x) => x !== deger));
|
||||||
|
} else {
|
||||||
|
if (maks && liste.length >= maks) {
|
||||||
|
toast.info(`En fazla ${maks} seçebilirsin.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setListe([...liste, deger]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uret(secimler: SihirbazSecimleri) {
|
async function uret(secimler: SihirbazSecimleri) {
|
||||||
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
if (!requestIdRef.current) requestIdRef.current = crypto.randomUUID();
|
||||||
setFaz("uretiliyor");
|
setFaz("uretiliyor");
|
||||||
@@ -182,8 +241,8 @@ export function SihirbazModal({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ilerle(secimler: SihirbazSecimleri) {
|
function ilerle() {
|
||||||
setSonSecimler(secimler);
|
const secimler = secimleriTopla();
|
||||||
if (!girisliMi) {
|
if (!girisliMi) {
|
||||||
secimleriKaydetVeGirise(secimler);
|
secimleriKaydetVeGirise(secimler);
|
||||||
return;
|
return;
|
||||||
@@ -196,17 +255,162 @@ export function SihirbazModal({
|
|||||||
void uret(secimler);
|
void uret(secimler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
|
||||||
|
|
||||||
// ---- Faz gövdeleri ----
|
// ---- Faz gövdeleri ----
|
||||||
|
|
||||||
const adimlarGovde = (
|
const adimlarGovde = (
|
||||||
<SihirbazAdimlar
|
<div className="flex flex-col gap-5">
|
||||||
facetler={facetler}
|
<div className="flex items-center gap-1.5">
|
||||||
baslangicSecimler={sonSecimler}
|
{[0, 1, 2].map((i) => (
|
||||||
sonButonEtiketi={
|
<span
|
||||||
hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"
|
key={i}
|
||||||
}
|
className={
|
||||||
onTamamla={ilerle}
|
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={ilerle}
|
||||||
|
disabled={kategoriler.length === 0}
|
||||||
|
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" aria-hidden />
|
||||||
|
{hasPaket ? "24'lük listemi oluştur (3 kredi)" : "Listemi gör"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const onizlemeGovde = (
|
const onizlemeGovde = (
|
||||||
@@ -313,7 +517,7 @@ export function SihirbazModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<RaporListesi rapor={rapor.rapor} adaySira={rapor.params.sira} />
|
<RaporListesi rapor={rapor.rapor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -340,22 +544,8 @@ export function SihirbazModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4">
|
<div className="mt-6">
|
||||||
<div>
|
<SoruKutusu kredi={kredi} onKrediDegisti={setKredi} />
|
||||||
<p className="font-heading text-sm font-bold">
|
|
||||||
Listen hakkında soruların mı var?
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
AI danışman listeni ve seçimlerini bilir; her mesaj 1 kredi.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="sm"
|
|
||||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
<Link href="/listem">Danışmana sor</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -2,112 +2,49 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, Loader2 } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
|
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
|
||||||
import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz";
|
|
||||||
|
|
||||||
const TUR: string = "say";
|
|
||||||
|
|
||||||
export function HeroForm() {
|
export function HeroForm() {
|
||||||
const [siralama, setSiralama] = useState("");
|
const [siralama, setSiralama] = useState("");
|
||||||
const [sira, setSira] = useState<number | null>(null);
|
|
||||||
const [facetler, setFacetler] = useState<SihirbazFacetleri | null>(null);
|
|
||||||
const [yukleniyor, setYukleniyor] = useState(false);
|
|
||||||
const [modalAcik, setModalAcik] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const value = Number(siralama.replace(/\./g, ""));
|
const value = Number(siralama.replace(/\./g, ""));
|
||||||
if (!value || value < 1 || value > 4_000_000) {
|
if (!value || value < 1 || value > 4_000_000) {
|
||||||
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setYukleniyor(true);
|
router.push(`/sonuc?sira=${value}`);
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/facetler?sira=${value}&tur=${TUR}`);
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
|
|
||||||
setSira(value);
|
|
||||||
setFacetler(veri.facetler);
|
|
||||||
setModalAcik(true);
|
|
||||||
} catch {
|
|
||||||
toast.error("Bir şeyler ters gitti, tekrar dener misin?");
|
|
||||||
} finally {
|
|
||||||
setYukleniyor(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seçimler tamam: sonuç sayfası (ve sonrası) kullanabilsin diye sakla, yönlendir.
|
|
||||||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
|
||||||
if (!sira) return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
SIHIRBAZ_STORAGE_KEY,
|
|
||||||
JSON.stringify({ sira, tur: TUR, secimler }),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
setModalAcik(false);
|
|
||||||
router.push(`/sonuc?sira=${sira}&tur=${TUR}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<form
|
||||||
<form
|
onSubmit={handleSubmit}
|
||||||
onSubmit={handleSubmit}
|
className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
|
||||||
className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
|
>
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<label htmlFor="siralama" className="sr-only">
|
Listemi oluştur
|
||||||
YKS başarı sıralaması
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
</label>
|
</Button>
|
||||||
<Input
|
</form>
|
||||||
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,54 +1,25 @@
|
|||||||
import { Rocket, Scale, ShieldCheck } from "lucide-react";
|
import { Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||||
import {
|
|
||||||
RISK_ETIKET,
|
|
||||||
dilimdenRisk,
|
|
||||||
riskHesapla,
|
|
||||||
type RiskSeviyesi,
|
|
||||||
} from "@/lib/risk";
|
|
||||||
|
|
||||||
export const DILIM_ETIKET: Record<string, string> = {
|
export const DILIM_STIL: Record<
|
||||||
hayal: "Hayal",
|
string,
|
||||||
dengeli: "Dengeli",
|
{ label: string; cls: string; icon: typeof Rocket }
|
||||||
garanti: "Garanti",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
|
|
||||||
export const RISK_STIL: Record<
|
|
||||||
RiskSeviyesi,
|
|
||||||
{ badge: string; kenar: string; icon: typeof Rocket }
|
|
||||||
> = {
|
> = {
|
||||||
riskli: {
|
hayal: { label: "Hayal", cls: "bg-orange-100 text-orange-700", icon: Rocket },
|
||||||
badge: "bg-red-100 text-red-700",
|
dengeli: { label: "Dengeli", cls: "bg-blue-100 text-blue-700", icon: Scale },
|
||||||
kenar: "border-l-red-500",
|
garanti: {
|
||||||
icon: Rocket,
|
label: "Garanti",
|
||||||
},
|
cls: "bg-emerald-100 text-emerald-700",
|
||||||
"az-riskli": {
|
|
||||||
badge: "bg-amber-100 text-amber-700",
|
|
||||||
kenar: "border-l-amber-500",
|
|
||||||
icon: Scale,
|
|
||||||
},
|
|
||||||
guvenli: {
|
|
||||||
badge: "bg-emerald-100 text-emerald-700",
|
|
||||||
kenar: "border-l-emerald-500",
|
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
|
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
|
||||||
* Hem sonuç modalında hem sohbet sayfasında kullanılır. Risk rengi
|
* Hem sonuç modalında hem PDF/yazdır sayfasında kullanılır.
|
||||||
* adayın sıralamasına göre deterministik hesaplanır (adaySira verilirse);
|
|
||||||
* eski raporlarda dilimden türetilir.
|
|
||||||
*/
|
*/
|
||||||
export function RaporListesi({
|
export function RaporListesi({ rapor }: { rapor: RaporSonuc }) {
|
||||||
rapor,
|
|
||||||
adaySira,
|
|
||||||
}: {
|
|
||||||
rapor: RaporSonuc;
|
|
||||||
adaySira?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-6">
|
<div className="rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
@@ -58,36 +29,14 @@ export function RaporListesi({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500">
|
<ol className="mt-6 space-y-3">
|
||||||
{(["guvenli", "az-riskli", "riskli"] as const).map((r) => (
|
|
||||||
<span key={r} className="inline-flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className={`size-2.5 rounded-full ${
|
|
||||||
r === "guvenli"
|
|
||||||
? "bg-emerald-500"
|
|
||||||
: r === "az-riskli"
|
|
||||||
? "bg-amber-500"
|
|
||||||
: "bg-red-500"
|
|
||||||
}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{RISK_ETIKET[r]}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="mt-3 space-y-3">
|
|
||||||
{rapor.tercihler.map((t) => {
|
{rapor.tercihler.map((t) => {
|
||||||
const p = rapor.programlar[t.programId];
|
const p = rapor.programlar[t.programId];
|
||||||
const risk =
|
const stil = DILIM_STIL[t.dilim];
|
||||||
(adaySira != null
|
|
||||||
? riskHesapla(p?.efektifSira, adaySira)
|
|
||||||
: null) ?? dilimdenRisk(t.dilim);
|
|
||||||
const stil = RISK_STIL[risk];
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={t.sira}
|
key={t.sira}
|
||||||
className={`rounded-2xl border border-l-4 border-slate-200 bg-white p-5 ${stil.kenar}`}
|
className="rounded-2xl border border-slate-200 bg-white p-5"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold">
|
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold">
|
||||||
@@ -98,14 +47,11 @@ export function RaporListesi({
|
|||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
{p?.universite} · {p?.il ?? "—"}
|
{p?.universite} · {p?.il ?? "—"}
|
||||||
{p?.unitur ? ` · ${p.unitur}` : ""}
|
{p?.unitur ? ` · ${p.unitur}` : ""}
|
||||||
{p?.efektifSira != null
|
|
||||||
? ` · taban ~${p.efektifSira.toLocaleString("tr-TR")}.`
|
|
||||||
: ""}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className={stil.badge}>
|
<Badge variant="secondary" className={stil.cls}>
|
||||||
<stil.icon className="size-3" aria-hidden />
|
<stil.icon className="size-3" aria-hidden />
|
||||||
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
|
{stil.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
|
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Sihirbazın 3 adımlık seçim gövdesi. Hem ana sayfadaki hero modalında hem de
|
|
||||||
// /sonuc'taki sihirbaz modalında kullanılır; seçim state'ini kendi tutar,
|
|
||||||
// tamamlanınca onTamamla(secimler) çağırır.
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { SihirbazFacetleri } from "@/lib/db";
|
|
||||||
import {
|
|
||||||
ONCELIKLER,
|
|
||||||
UNIVERSITE_TIPI_ETIKET,
|
|
||||||
type SihirbazSecimleri,
|
|
||||||
type UniversiteTipi,
|
|
||||||
} from "@/lib/sihirbaz";
|
|
||||||
|
|
||||||
export function Cip({
|
|
||||||
secili,
|
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
secili: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-pressed={secili}
|
|
||||||
className={
|
|
||||||
secili
|
|
||||||
? "cursor-pointer rounded-full border border-primary bg-primary px-3.5 py-2 text-sm font-medium text-white transition-colors duration-200"
|
|
||||||
: "cursor-pointer rounded-full border border-slate-200 bg-white px-3.5 py-2 text-sm text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SihirbazAdimlar({
|
|
||||||
facetler,
|
|
||||||
baslangicSecimler,
|
|
||||||
sonButonEtiketi,
|
|
||||||
onTamamla,
|
|
||||||
}: {
|
|
||||||
facetler: SihirbazFacetleri;
|
|
||||||
baslangicSecimler?: SihirbazSecimleri | null;
|
|
||||||
sonButonEtiketi: string;
|
|
||||||
onTamamla: (secimler: SihirbazSecimleri) => void;
|
|
||||||
}) {
|
|
||||||
const [adim, setAdim] = useState(0);
|
|
||||||
const [kategoriler, setKategoriler] = useState<string[]>(
|
|
||||||
baslangicSecimler?.kategoriler ?? [],
|
|
||||||
);
|
|
||||||
const [iller, setIller] = useState<string[]>(baslangicSecimler?.iller ?? []);
|
|
||||||
const [universiteTipi, setUniversiteTipi] = useState<UniversiteTipi>(
|
|
||||||
baslangicSecimler?.universiteTipi ?? "farketmez",
|
|
||||||
);
|
|
||||||
const [oncelikler, setOncelikler] = useState<string[]>(
|
|
||||||
baslangicSecimler?.oncelikler ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
function listeDegistir(
|
|
||||||
liste: string[],
|
|
||||||
setListe: (v: string[]) => void,
|
|
||||||
deger: string,
|
|
||||||
maks?: number,
|
|
||||||
) {
|
|
||||||
if (liste.includes(deger)) {
|
|
||||||
setListe(liste.filter((x) => x !== deger));
|
|
||||||
} else {
|
|
||||||
if (maks && liste.length >= maks) {
|
|
||||||
toast.info(`En fazla ${maks} seçebilirsin.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setListe([...liste, deger]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const adimGecerli = adim === 0 ? kategoriler.length > 0 : true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={
|
|
||||||
i === adim
|
|
||||||
? "h-1.5 w-6 rounded-full bg-primary transition-all"
|
|
||||||
: "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<span className="ml-2 text-xs font-medium text-slate-400">
|
|
||||||
Adım {adim + 1}/3
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{adim === 0 ? (
|
|
||||||
<div>
|
|
||||||
<h3 className="font-heading text-lg font-bold">
|
|
||||||
Hangi alanlara ilgi duyuyorsun?
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
Sıralamanla ulaşabileceğin alanlar. En az bir tane seç.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{facetler.kategoriler.map((k) => (
|
|
||||||
<Cip
|
|
||||||
key={k.ad}
|
|
||||||
secili={kategoriler.includes(k.ad)}
|
|
||||||
onClick={() => listeDegistir(kategoriler, setKategoriler, k.ad)}
|
|
||||||
>
|
|
||||||
{k.ad}
|
|
||||||
<span className="ml-1.5 text-xs opacity-70">{k.adet}</span>
|
|
||||||
</Cip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : adim === 1 ? (
|
|
||||||
<div>
|
|
||||||
<h3 className="font-heading text-lg font-bold">
|
|
||||||
Nerede okumak istersin?
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
İstersen boş bırak (fark etmez). En fazla 5 il.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{facetler.iller.map((i) => (
|
|
||||||
<Cip
|
|
||||||
key={i.il}
|
|
||||||
secili={iller.includes(i.il)}
|
|
||||||
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
|
|
||||||
>
|
|
||||||
{i.il}
|
|
||||||
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
|
|
||||||
</Cip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-heading text-lg font-bold">
|
|
||||||
Üniversite tipi & öncelikler
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
Bunlar listenin dengesini belirler.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
|
|
||||||
Üniversite tipi
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{facetler.uniturler.map((u) => (
|
|
||||||
<Cip
|
|
||||||
key={u.grup}
|
|
||||||
secili={universiteTipi === u.grup}
|
|
||||||
onClick={() =>
|
|
||||||
setUniversiteTipi(
|
|
||||||
universiteTipi === u.grup ? "farketmez" : u.grup,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{UNIVERSITE_TIPI_ETIKET[u.grup]}
|
|
||||||
<span className="ml-1.5 text-xs opacity-70">{u.adet}</span>
|
|
||||||
</Cip>
|
|
||||||
))}
|
|
||||||
<Cip
|
|
||||||
secili={universiteTipi === "farketmez"}
|
|
||||||
onClick={() => setUniversiteTipi("farketmez")}
|
|
||||||
>
|
|
||||||
Fark etmez
|
|
||||||
</Cip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
|
|
||||||
Senin için önemli olan
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{ONCELIKLER.map((o) => (
|
|
||||||
<Cip
|
|
||||||
key={o}
|
|
||||||
secili={oncelikler.includes(o)}
|
|
||||||
onClick={() => listeDegistir(oncelikler, setOncelikler, o)}
|
|
||||||
>
|
|
||||||
{o}
|
|
||||||
</Cip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setAdim((a) => a - 1)}
|
|
||||||
disabled={adim === 0}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" aria-hidden />
|
|
||||||
Geri
|
|
||||||
</Button>
|
|
||||||
{adim < 2 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAdim((a) => a + 1)}
|
|
||||||
disabled={!adimGecerli}
|
|
||||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
Devam
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
onTamamla({ kategoriler, iller, universiteTipi, oncelikler })
|
|
||||||
}
|
|
||||||
disabled={kategoriler.length === 0}
|
|
||||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
<Sparkles className="size-4" aria-hidden />
|
|
||||||
{sonButonEtiketi}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type AdayProgram,
|
type AdayProgram,
|
||||||
} from "../rapor-havuzu";
|
} from "../rapor-havuzu";
|
||||||
import { secimOzeti } from "../sihirbaz";
|
import { secimOzeti } from "../sihirbaz";
|
||||||
import { RISK_ETIKET, dilimdenRisk, riskHesapla } from "../risk";
|
|
||||||
|
|
||||||
const TercihSchema = z.object({
|
const TercihSchema = z.object({
|
||||||
programId: z.string(),
|
programId: z.string(),
|
||||||
@@ -27,16 +26,9 @@ const RaporSchema = z.object({
|
|||||||
|
|
||||||
export type RaporSonuc = z.infer<typeof RaporSchema> & {
|
export type RaporSonuc = z.infer<typeof RaporSchema> & {
|
||||||
// Render için havuzdan zenginleştirilmiş program bilgisi
|
// Render için havuzdan zenginleştirilmiş program bilgisi
|
||||||
// efektifSira: COALESCE(sira2025, sira2024) — eski kayıtlı raporlarda yok
|
|
||||||
programlar: Record<
|
programlar: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{ isim: string; universite: string; il: string | null; unitur: string | null }
|
||||||
isim: string;
|
|
||||||
universite: string;
|
|
||||||
il: string | null;
|
|
||||||
unitur: string | null;
|
|
||||||
efektifSira?: number | null;
|
|
||||||
}
|
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,25 +162,17 @@ export async function raporUret(
|
|||||||
universite: p.universite,
|
universite: p.universite,
|
||||||
il: p.il,
|
il: p.il,
|
||||||
unitur: p.unitur,
|
unitur: p.unitur,
|
||||||
efektifSira: p.sira2025 ?? p.sira2024,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ...sonuc, programlar };
|
return { ...sonuc, programlar };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listeOzetiCikar(rapor: RaporSonuc, adaySira?: number): string {
|
export function listeOzetiCikar(rapor: RaporSonuc): string {
|
||||||
return rapor.tercihler
|
return rapor.tercihler
|
||||||
.map((t) => {
|
.map(
|
||||||
const p = rapor.programlar[t.programId];
|
(t) =>
|
||||||
const risk =
|
`${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"} — ${rapor.programlar[t.programId]?.universite ?? "?"}`,
|
||||||
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
)
|
||||||
dilimdenRisk(t.dilim);
|
|
||||||
const taban =
|
|
||||||
p?.efektifSira != null
|
|
||||||
? ` (taban sırası ~${p.efektifSira.toLocaleString("tr-TR")})`
|
|
||||||
: "";
|
|
||||||
return `${t.sira}. [${t.dilim} | ${RISK_ETIKET[risk]}] ${p?.isim ?? "?"} — ${p?.universite ?? "?"}${taban}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { Dilim } from "./rapor-havuzu";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Satır bazlı yerleşme riski. Renk dili ürünün her yerinde aynı:
|
|
||||||
* yeşil = güvenli, sarı = az riskli, kırmızı = riskli.
|
|
||||||
*/
|
|
||||||
export type RiskSeviyesi = "guvenli" | "az-riskli" | "riskli";
|
|
||||||
|
|
||||||
export const RISK_ETIKET: Record<RiskSeviyesi, string> = {
|
|
||||||
guvenli: "Güvenli",
|
|
||||||
"az-riskli": "Az riskli",
|
|
||||||
riskli: "Riskli",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministik risk: programın efektif taban sıralaması (COALESCE 2025→2024)
|
|
||||||
* adayın sıralamasıyla kıyaslanır. Eşikler db.ts'teki dilim aralıklarıyla
|
|
||||||
* birebir aynı (dengeli: [sira, 1.4×sira], garanti: (1.4×sira, 3×sira]).
|
|
||||||
*/
|
|
||||||
export function riskHesapla(
|
|
||||||
efektifSira: number | null | undefined,
|
|
||||||
adaySira: number,
|
|
||||||
): RiskSeviyesi | null {
|
|
||||||
if (efektifSira == null || !Number.isFinite(adaySira) || adaySira < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (efektifSira > Math.round(adaySira * 1.4)) return "guvenli";
|
|
||||||
if (efektifSira >= adaySira) return "az-riskli";
|
|
||||||
return "riskli";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eski raporlar için (efektifSira kaydedilmemiş): dilimden risk türet. */
|
|
||||||
export function dilimdenRisk(dilim: Dilim | string): RiskSeviyesi {
|
|
||||||
if (dilim === "garanti") return "guvenli";
|
|
||||||
if (dilim === "dengeli") return "az-riskli";
|
|
||||||
return "riskli";
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user