19 Commits

Author SHA1 Message Date
bilalgursen
2c02e3d7e9 Sihirbaz akışı ana sayfaya taşındı: sıralama girilince modal orada açılır
Some checks failed
Deploy / deploy (push) Failing after 1m30s
- Ortak SihirbazAdimlar bileşeni (3 adımlık seçim UI'ı) çıkarıldı;
  hem hero modalı hem /sonuc modalı kullanıyor
- /api/facetler: sıraya göre kategori/il/üniversite tipi facet'leri
- HeroForm: "Listemi oluştur" artık modalı açar; seçimler tamamlanınca
  localStorage'a (SIHIRBAZ_STORAGE_KEY) yazılıp /sonuc'a yönlendirilir
- /sonuc: modal yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır;
  kayıtlı seçimler prefill olarak kullanılır

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

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

15
.dockerignore Normal file
View File

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

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

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

55
Dockerfile Normal file
View File

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

27
docker-compose.yml Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "KolayTercih — Cebindeki Tercih Danışmanı",
title: "KolayTercih — Tercih Danışmanı",
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ığı.",
};

View File

@@ -151,8 +151,11 @@ export default function Home() {
<main className="flex-1">
{/* Hero */}
<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">
2026 YKS tercih dönemi ık
<Badge
variant="secondary"
className="mb-6 bg-primary/10 text-primary"
>
2026 YKS tercih dönemi ıkk
</Badge>
<h1 className="max-w-3xl font-heading text-4xl font-bold tracking-tight sm:text-6xl">
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
liste, risk analizi, soru-cevap onda bir fiyatına yapar.
</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 />
</div>
<p className="mt-4 text-sm text-slate-500">

View File

@@ -1,22 +1,11 @@
import Link from "next/link";
import type { Metadata } from "next";
import {
ArrowLeft,
MapPin,
Minus,
Rocket,
Scale,
ShieldCheck,
TrendingDown,
TrendingUp,
X,
} from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { eq } from "drizzle-orm";
import {
PUAN_TURLERI,
searchByRank,
rankWindowFacets,
type Program,
type PuanTuruKey,
} from "@/lib/db";
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 { RaporParams } from "@/lib/rapor-havuzu";
import { SihirbazBolumu } from "./sihirbaz-cagri-karti";
import type {
MevcutRapor,
OnizlemeSatir,
} from "./sihirbaz-modal";
import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi";
import type { MevcutRapor, OnizlemeSatir } from "./sihirbaz-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const metadata: Metadata = {
title: "Sıralamana Uygun Programlar — KolayTercih",
title: "Tercih Listeni Kur — KolayTercih",
};
const TUR_LABELS: Record<PuanTuruKey, string> = {
@@ -52,106 +29,12 @@ const TUR_LABELS: Record<PuanTuruKey, string> = {
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({
searchParams,
}: {
searchParams: Promise<{
sira?: string;
tur?: string;
il?: string;
sihirbaz?: string;
}>;
}) {
@@ -161,7 +44,6 @@ export default async function SonucPage({
params.tur && params.tur in PUAN_TURLERI
? (params.tur as PuanTuruKey)
: "say";
const seciliIl = params.il?.trim() || null;
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return (
@@ -170,7 +52,7 @@ export default async function SonucPage({
Geçerli bir sıralama gerekli
</h1>
<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>
<Button asChild className="mt-6 cursor-pointer">
<Link href="/">
@@ -211,65 +93,12 @@ export default async function SonucPage({
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 (
<div className="min-h-screen bg-slate-50 text-slate-900">
<main
className={`mx-auto max-w-6xl px-4 py-12 ${mevcutRapor ? "" : "pb-56"}`}
>
<main className="mx-auto max-w-4xl px-4 py-12">
<div className="flex items-center justify-between gap-3">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Ücretsiz ön izleme
{sira.toLocaleString("tr-TR")}. sıra · {TUR_LABELS[turKey]}
</Badge>
<Button
asChild
@@ -284,12 +113,15 @@ export default async function SonucPage({
</Button>
</div>
<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>
<p className="mt-2 max-w-2xl text-slate-600">
2025 YÖK Atlas taban sıralamalarına göre üç dilime ayrılmış öneriler.
Taban sıralamaları her yıl değişir; bu liste garanti değil, başlangıç
noktasıdır.
Birkaç adımda ilgi alanlarını ve şehir tercihini seç; yapay zekâ
gerçek YÖK Atlas verisiyle 24 tercihlik dengeli listeni kursun. Her
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>
{/* Puan türü seçimi */}
@@ -302,82 +134,15 @@ export default async function SonucPage({
variant={key === turKey ? "default" : "outline"}
className="cursor-pointer"
>
<Link
href={`/sonuc?sira=${sira}&tur=${key}${
seciliIl ? `&il=${encodeURIComponent(seciliIl)}` : ""
}`}
>
<Link href={`/sonuc?sira=${sira}&tur=${key}`}>
{TUR_LABELS[key]}
</Link>
</Button>
))}
</nav>
{seciliIl ? (
<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) */}
{/* Sihirbaz akışı (CTA + modal) — seçimler ana sayfadaki modalda yapılır,
burada yalnızca giriş dönüşünde (?sihirbaz=1) otomatik açılır */}
<SihirbazBolumu
sira={sira}
tur={turKey}

View File

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

View File

@@ -2,26 +2,67 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
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() {
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();
function handleSubmit(e: React.FormEvent) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const value = Number(siralama.replace(/\./g, ""));
if (!value || value < 1 || value > 4_000_000) {
toast.error("Geçerli bir başarı sıralaması gir (ör. 85.000).");
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 (
<>
<form
onSubmit={handleSubmit}
className="flex w-full max-w-md flex-col gap-3 sm:flex-row"
@@ -40,11 +81,33 @@ export function HeroForm() {
<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
<ArrowRight className="size-4" aria-hidden />
{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>
</>
);
}

View File

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