feat: kimlik doğrulama, ödeme, kredi ve AI rapor sihirbazı ekle

- better-auth ile giriş/oturum yönetimi (src/lib/auth, giris sayfası)
- iyzico ödeme entegrasyonu ve paket satın alma akışı
- kredi sistemi ve uygulama veritabanı şeması (drizzle)
- AI destekli rapor üretimi ve tercih sihirbazı
- rapor yazdırma sayfası ve site header/kullanıcı menüsü

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bilalgursen
2026-07-22 02:13:39 +03:00
parent 7c3f17b9b5
commit cee6200237
64 changed files with 7966 additions and 89 deletions

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -0,0 +1,30 @@
import { eq } from "drizzle-orm";
import { NextResponse, type NextRequest } from "next/server";
import { appDb, schema } from "@/lib/appdb";
import { odemeyiSonuclandir } from "@/lib/odeme";
// DİKKAT: Bu route iyzico'dan gelen cross-site form POST'udur.
// SameSite=Lax nedeniyle session çerezi GELMEZ — kullanıcı token'dan çözülür,
// verifySession ÇAĞRILMAZ. Kredi tanımlama odemeyiSonuclandir içinde idempotenttir.
export async function POST(request: NextRequest) {
const form = await request.formData();
const token = form.get("token");
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
if (typeof token !== "string" || !token) {
return NextResponse.redirect(`${appUrl}/paket?hata=token`, 303);
}
const order = await appDb.query.orders.findFirst({
where: eq(schema.orders.iyzicoToken, token),
});
if (!order) {
return NextResponse.redirect(`${appUrl}/paket?hata=siparis`, 303);
}
await odemeyiSonuclandir(order.id);
return NextResponse.redirect(
`${appUrl}/odeme/sonuc?siparis=${order.id}`,
303,
);
}

237
src/app/api/soru/route.ts Normal file
View File

@@ -0,0 +1,237 @@
import { eq, desc, asc } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { spendCreditForMessage, grantCredits } from "@/lib/credits";
import { getAnthropic, SOHBET_MODEL, SORUMLULUK_REDDI } from "@/lib/ai/client";
import { listeOzetiCikar, type RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { secimOzeti } from "@/lib/sihirbaz";
import { PUAN_TURLERI } from "@/lib/db";
const MAX_GECMIS = 12;
const MAX_MESAJ_UZUNLUK = 2000;
function sistemPromptu(opts: { params: RaporParams; listeOzeti: string }): string {
const { params } = opts;
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
const parcalar = [
`Sen KolayTercih'in YKS tercih danışmanısın. Türkçe, samimi ama profesyonel konuş.
Aday az önce sana verilen 24'lük tercih listesini oluşturdu. Görevin: adayın bu
liste ve seçimleri BAĞLAMINDA sorularını (bölüm kıyaslama, şehir, risk, kontenjan,
iş imkânları, tercih sırası) net ve dürüst yanıtlamak.
KURALLAR:
- Cevaplarını adayın 24'lük listesi ve seçimleri bağlamında ver; listedeki
programlara referans verebilirsin.
- ASLA yerleşme garantisi verme; "yüksek/orta/düşük ihtimal" dili kullan.
- Bilmediğin güncel veriyi uydurma; emin değilsen YÖK Atlas'a bakmasını söyle.
- Taban sıralamalarının her yıl değiştiğini gerektiğinde hatırlat.
- Cevapları kısa ve öz tut (genelde 3-6 cümle); istenirse detaylandır.
- Tercih dışı konularda (ödev, kod, genel sohbet) kibarca reddet ve tercih
konusuna dön.
- Kesin konuşmaktan kaçın: "${SORUMLULUK_REDDI}"`,
`ADAY BİLGİSİ: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[params.tur]}.`,
`ADAYIN 24'LÜK LİSTESİ:\n${opts.listeOzeti}`,
];
if (secimVar) {
parcalar.push(
`ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
kategoriler: params.kategoriler ?? [],
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
})}`,
);
}
return parcalar.join("\n\n");
}
// Widget açıldığında geçmişi ve kredi bakiyesini döner
export async function GET() {
const session = await getSession();
if (!session) {
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
}
const [mesajlar, kullanici] = await Promise.all([
appDb
.select({
id: schema.chatMessages.id,
role: schema.chatMessages.role,
content: schema.chatMessages.content,
})
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(asc(schema.chatMessages.createdAt))
.limit(100),
appDb.query.user.findFirst({
where: eq(schema.user.id, session.user.id),
columns: { creditBalance: true },
}),
]);
return Response.json({
mesajlar,
kredi: kullanici?.creditBalance ?? 0,
});
}
export async function POST(request: NextRequest) {
const session = await getSession();
if (!session) {
return Response.json({ error: "Oturum gerekli." }, { status: 401 });
}
let body: { message?: unknown; clientMessageId?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: "Geçersiz istek." }, { status: 400 });
}
const mesaj = typeof body.message === "string" ? body.message.trim() : "";
const clientMessageId =
typeof body.clientMessageId === "string" ? body.clientMessageId : "";
if (!mesaj || mesaj.length > MAX_MESAJ_UZUNLUK || !clientMessageId) {
return Response.json({ error: "Geçersiz mesaj." }, { status: 400 });
}
// Soru sormak için önce liste oluşturulmuş olmalı
const rapor = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, session.user.id),
});
if (!rapor?.result) {
return Response.json(
{ error: "Önce listeni oluşturmalısın.", code: "NO_REPORT" },
{ status: 409 },
);
}
// Geçmişi krediyi düşmeden ÖNCE oku ki kullanıcı mesajı dahil olmasın
const gecmis = (
await appDb
.select()
.from(schema.chatMessages)
.where(eq(schema.chatMessages.userId, session.user.id))
.orderBy(desc(schema.chatMessages.createdAt))
.limit(MAX_GECMIS)
).reverse();
// Kredi düşümü stream'den önce — yetersizse hiç başlama
const harcama = await spendCreditForMessage({
userId: session.user.id,
content: mesaj,
clientMessageId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
return Response.json(
{ error: "Kredin bitti.", code: "INSUFFICIENT" },
{ status: 402 },
);
}
return Response.json(
{ error: "Bu mesaj zaten işlendi.", code: "DUPLICATE" },
{ status: 409 },
);
}
const kullaniciMesajId = harcama.messageId!;
const system = sistemPromptu({
params: rapor.params as RaporParams,
listeOzeti: listeOzetiCikar(rapor.result as RaporSonuc),
});
let client;
try {
client = getAnthropic();
} catch {
await grantCredits({
userId: session.user.id,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
});
return Response.json(
{ error: "AI altyapısı yapılandırılmadı." },
{ status: 503 },
);
}
const mesajlar = [
...gecmis.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user" as const, content: mesaj },
];
const encoder = new TextEncoder();
const userId = session.user.id;
let stream;
try {
stream = client.messages.stream({
model: SOHBET_MODEL,
max_tokens: 1024,
system,
messages: mesajlar,
});
} catch {
await grantCredits({ userId, delta: 1, reason: "refund", refId: kullaniciMesajId });
return Response.json({ error: "AI'ya ulaşılamadı." }, { status: 502 });
}
const readable = new ReadableStream({
async start(controller) {
let tamamlandi = false;
try {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
controller.enqueue(encoder.encode(event.delta.text));
}
}
const final = await stream.finalMessage();
const metin = final.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
await appDb.insert(schema.chatMessages).values({
id: crypto.randomUUID(),
userId,
role: "assistant",
content: metin,
createdAt: new Date(),
});
tamamlandi = true;
controller.close();
} catch (err) {
if (!tamamlandi) {
// Hiç çıktı üretilmeden hata olduysa krediyi iade et
await grantCredits({
userId,
delta: 1,
reason: "refund",
refId: kullaniciMesajId,
}).catch(() => {});
}
console.error("[soru] stream hatası:", err);
controller.error(err);
}
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "no-store",
},
});
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { Loader2, Mail } from "lucide-react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
function GoogleIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" aria-hidden>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.84 14.1a6.6 6.6 0 0 1 0-4.2V7.06H2.18a11 11 0 0 0 0 9.88l3.66-2.84Z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15A11 11 0 0 0 2.18 7.06L5.84 9.9C6.71 7.31 9.14 5.38 12 5.38Z"
/>
</svg>
);
}
export function GirisForm({ callbackURL }: { callbackURL: string }) {
const [email, setEmail] = useState("");
const [busy, setBusy] = useState<"google" | "email" | null>(null);
const [sent, setSent] = useState(false);
async function googleIleGiris() {
setBusy("google");
const { error } = await authClient.signIn.social({
provider: "google",
callbackURL,
});
if (error) {
toast.error("Google ile giriş başarısız. Tekrar dener misin?");
setBusy(null);
}
}
async function linkGonder(e: React.FormEvent) {
e.preventDefault();
if (!/^\S+@\S+\.\S+$/.test(email)) {
toast.error("Geçerli bir e-posta adresi gir.");
return;
}
setBusy("email");
const { error } = await authClient.signIn.magicLink({
email,
callbackURL,
});
setBusy(null);
if (error) {
toast.error("Bağlantı gönderilemedi. Tekrar dener misin?");
return;
}
setSent(true);
}
if (sent) {
return (
<div className="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
<p className="font-semibold">Bağlantı gönderildi 📬</p>
<p className="mt-1">
<span className="font-medium">{email}</span> adresine bir giriş
bağlantısı gönderdik. Gelen kutunu (ve spam klasörünü) kontrol et
bağlantı 5 dakika geçerli.
</p>
</div>
);
}
return (
<div className="mt-6 space-y-4">
<Button
variant="outline"
className="h-11 w-full cursor-pointer"
onClick={googleIleGiris}
disabled={busy !== null}
>
{busy === "google" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<GoogleIcon />
)}
Google ile devam et
</Button>
<div className="flex items-center gap-3 text-xs text-slate-400">
<div className="h-px flex-1 bg-slate-200" />
veya
<div className="h-px flex-1 bg-slate-200" />
</div>
<form onSubmit={linkGonder} className="space-y-3">
<Input
type="email"
inputMode="email"
autoComplete="email"
placeholder="E-posta adresin"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy !== null}
className="h-11"
/>
<Button
type="submit"
className="h-11 w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
disabled={busy !== null}
>
{busy === "email" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<Mail className="size-4" aria-hidden />
)}
Giriş bağlantısı gönder
</Button>
</form>
</div>
);
}

40
src/app/giris/page.tsx Normal file
View File

@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { GirisForm } from "./giris-form";
export const metadata: Metadata = {
title: "Giriş — KolayTercih",
};
export default async function GirisPage({
searchParams,
}: {
searchParams: Promise<{ callback?: string }>;
}) {
const { callback } = await searchParams;
const callbackURL =
callback && callback.startsWith("/") ? callback : "/";
const session = await getSession();
if (session) redirect(callbackURL);
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col items-stretch justify-center px-4 py-16">
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 className="font-heading text-2xl font-bold">Giriş yap</h1>
<p className="mt-2 text-sm text-slate-600">
Kayıt olduğunda <span className="font-semibold">5 deneme kredisi</span>{" "}
hesabına tanımlanır AI danışmana ücretsiz soru sorabilirsin.
</p>
<GirisForm callbackURL={callbackURL} />
</div>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-500">
Giriş yaparak verilerinin tercih önerileri üretmek amacıyla
işlenmesini kabul etmiş olursun. KolayTercih yerleşme garantisi vermez.
</p>
</main>
</div>
);
}

View File

@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Outfit, Work_Sans, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import "./globals.css";
const outfit = Outfit({
@@ -19,7 +22,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "TercihAI — Cebindeki Tercih Danışmanı",
title: "KolayTercih — Cebindeki 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ığı.",
};
@@ -34,7 +37,27 @@ export default function RootLayout({
lang="tr"
className={`${outfit.variable} ${workSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<body className="min-h-full flex flex-col bg-slate-50">
{/* Site geneli progressive blur — üst ve alt */}
<div className="pointer-events-none fixed inset-x-0 top-0 z-40 h-28">
<ProgressiveBlur
position="top"
backgroundColor="#f8fafc"
height="112px"
blurAmount="6px"
/>
</div>
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-28">
<ProgressiveBlur
position="bottom"
backgroundColor="#f8fafc"
height="112px"
blurAmount="6px"
/>
</div>
<HeaderGate>
<SiteHeader />
</HeaderGate>
{children}
<Toaster position="top-center" />
</body>

View File

@@ -0,0 +1,96 @@
import Link from "next/link";
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { CheckCircle2, Sparkles, XCircle } from "lucide-react";
import { verifySession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { odemeyiSonuclandir } from "@/lib/odeme";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = {
title: "Ödeme Sonucu — KolayTercih",
};
export default async function OdemeSonucPage({
searchParams,
}: {
searchParams: Promise<{ siparis?: string }>;
}) {
const { siparis } = await searchParams;
const session = await verifySession("/paket");
const order = siparis
? await appDb.query.orders.findFirst({
where: eq(schema.orders.id, siparis),
})
: null;
// Yalnızca kendi siparişi görüntülenebilir
const sahibiMi = order?.userId === session.user.id;
// Self-healing: callback kaybolduysa burada tekrar doğrula
const durum =
order && sahibiMi
? order.status === "paid"
? "paid"
: await odemeyiSonuclandir(order.id)
: "not_found";
const basarili = durum === "paid";
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
{basarili ? (
<div className="rounded-2xl border border-emerald-200 bg-white p-8 shadow-sm">
<CheckCircle2
className="mx-auto size-12 text-emerald-500"
aria-hidden
/>
<h1 className="mt-4 font-heading text-2xl font-bold">
Ödeme alındı 🎉
</h1>
<p className="mt-2 text-sm text-slate-600">
<span className="font-semibold">
{order!.credits} kredi
</span>{" "}
hesabına tanımlandı
{order!.product === "paket"
? " ve Tercih Dönemi Paketi'n aktif — kişisel listen, risk raporun ve PDF çıktın hazır olduğunda burada."
: "."}
</p>
<div className="mt-6 flex flex-col gap-2">
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/">
<Sparkles className="size-4" aria-hidden />
24&apos;lük listeni oluştur
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Sıralamanı girip sonuç sayfasındaki sihirbazla listeni kur.
</p>
</div>
</div>
) : (
<div className="rounded-2xl border border-red-200 bg-white p-8 shadow-sm">
<XCircle className="mx-auto size-12 text-red-500" aria-hidden />
<h1 className="mt-4 font-heading text-2xl font-bold">
Ödeme tamamlanamadı
</h1>
<p className="mt-2 text-sm text-slate-600">
{durum === "pending"
? "Ödemen hâlâ işleniyor. Birkaç saniye sonra bu sayfayı yenile — kartından çekim yapıldıysa krediler mutlaka tanımlanır."
: "Çekim yapılmadı ya da işlem iptal edildi. Kartından para çekildiğini düşünüyorsan bizimle iletişime geç."}
</p>
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
<Link href="/paket">Tekrar dene</Link>
</Button>
</div>
)}
</main>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import {
ListChecks,
MessageCircleQuestion,
ShieldCheck,
Sparkles,
TrendingUp,
Wallet,
X,
@@ -61,7 +60,7 @@ const steps = [
step: "1",
title: "Sıralamanı ve hedeflerini anlat",
description:
"YKS başarı sıralamanı gir; şehir, bölüm ve kariyer tercihlerini sohbet ederek netleştirelim.",
"YKS başarı sıralamanı gir; sıralamana uygun ilgi alanı, şehir ve üniversite tercihlerini birkaç adımda işaretle.",
},
{
icon: BarChart3,
@@ -75,20 +74,20 @@ const steps = [
step: "3",
title: "Dengeli 24'lük listeni al",
description:
"Hayal, dengeli ve garanti dilimlerine dağıtılmış, her satırı gerekçeli tercih listeni indir; aklına takılanı danışmanına sor.",
"Hayal, dengeli ve garanti dilimlerine dağıtılmış, her satırı gerekçeli tercih listeni indir; aklına takılanı listen üzerinden yapay zekâya sor.",
},
];
const faqs = [
{
question: "TercihAI'nin önerileri neye dayanıyor?",
question: "KolayTercih'in önerileri neye dayanıyor?",
answer:
"Tüm öneriler YÖK Atlas'ın resmî verisine dayanır: son 4 yılın taban başarı sıralamaları, kontenjanlar, yerleşme istatistikleri ve doluluk oranları. Yapay zekâ bu veriyi yorumlar; veri olmadan tahmin yürütmez.",
},
{
question: "Ücretsiz tercih robotlarından farkı ne?",
answer:
"Ücretsiz robotlar puan aralığına göre bölüm listeler ve gerisini sana bırakır. TercihAI ise listenin kendisini kurar: hangi tercihi kaçıncı sıraya, neden koyduğunu açıklar, riskini söyler ve sorularını cevaplar. Yani filtre değil, danışmandır.",
"Ücretsiz robotlar puan aralığına göre bölüm listeler ve gerisini sana bırakır. KolayTercih ise listenin kendisini kurar: hangi tercihi kaçıncı sıraya, neden koyduğunu açıklar, riskini söyler ve sorularını cevaplar. Yani filtre değil, danışmandır.",
},
{
question: "Yerleşme garantisi veriyor musunuz?",
@@ -98,7 +97,7 @@ const faqs = [
{
question: "Fiyatlandırma nasıl çalışıyor?",
answer:
"Abonelik yok. Tercih dönemi boyunca geçerli tek seferlik paket alırsın: yapay zekâ danışman sohbeti, kişisel 24'lük liste, risk analizi ve liste revizyonları dahil. Temel program arama ise herkes için ücretsiz.",
"Abonelik yok. Tercih dönemi boyunca geçerli tek seferlik paket alırsın: yapay zekâ destekli kişisel 24'lük liste, risk analizi, liste revizyonları ve listen hakkında soru hakkı dahil. Temel program arama ise herkes için ücretsiz.",
},
];
@@ -149,30 +148,6 @@ function ComparisonCell({ value }: { value: boolean | string }) {
export default function Home() {
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
{/* Navbar */}
<header className="sticky top-4 z-50 mx-4">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between rounded-2xl border border-slate-200 bg-white/80 px-5 shadow-sm backdrop-blur">
<a href="#" className="flex items-center gap-2 font-heading text-lg font-bold">
<Sparkles className="size-5 text-primary" aria-hidden />
TercihAI
</a>
<nav className="hidden items-center gap-6 text-sm font-medium text-slate-600 sm:flex">
<a href="#nasil-calisir" className="transition-colors duration-200 hover:text-slate-900">
Nasıl çalışır?
</a>
<a href="#karsilastirma" className="transition-colors duration-200 hover:text-slate-900">
Karşılaştır
</a>
<a href="#sss" className="transition-colors duration-200 hover:text-slate-900">
SSS
</a>
</nav>
<Button asChild size="sm" className="cursor-pointer">
<a href="#hero-form">Hemen başla</a>
</Button>
</div>
</header>
<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">
@@ -184,7 +159,7 @@ export default function Home() {
<span className="text-primary">48 saatlik panikle</span> seçme
</h1>
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-slate-600">
TercihAI, gerçek YÖK Atlas verisiyle çalışan yapay zekâ tercih
KolayTercih, gerçek YÖK Atlas verisiyle çalışan yapay zekâ tercih
danışmanın. İnsan danışmanın binlerce lira aldığı işi dengeli
liste, risk analizi, soru-cevap onda bir fiyatına yapar.
</p>
@@ -276,7 +251,7 @@ export default function Home() {
>
<div className="mx-auto max-w-4xl px-4">
<h2 className="text-center font-heading text-3xl font-bold sm:text-4xl">
Robot mu, danışman mı, TercihAI mı?
Robot mu, danışman mı, KolayTercih mi?
</h2>
<div className="mt-12 overflow-x-auto rounded-2xl border border-slate-200 bg-white">
<Table>
@@ -290,7 +265,7 @@ export default function Home() {
İnsan danışman
</TableHead>
<TableHead className="text-center font-heading font-bold text-primary">
TercihAI
KolayTercih
</TableHead>
</TableRow>
</TableHeader>
@@ -363,11 +338,11 @@ export default function Home() {
<footer className="border-t border-slate-200 bg-white py-10">
<div className="mx-auto max-w-6xl px-4 text-center text-sm leading-relaxed text-slate-500">
<p>
© 2026 TercihAI. Veriler resmî YÖK Atlas kaynağından derlenir;
TercihAI, ÖSYM veya YÖK ile bağlantılı değildir.
© 2026 KolayTercih. Veriler resmî YÖK Atlas kaynağından derlenir;
KolayTercih, ÖSYM veya YÖK ile bağlantılı değildir.
</p>
<p className="mt-2">
TercihAI bir karar destek aracıdır, yerleşme garantisi vermez.
KolayTercih bir karar destek aracıdır, yerleşme garantisi vermez.
Tercih listenizin son hali ve başvuru sorumluluğu size aittir.
</p>
</div>

73
src/app/paket/actions.ts Normal file
View File

@@ -0,0 +1,73 @@
"use server";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { verifySession } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { URUNLER } from "@/lib/credits";
import { initializeCheckoutForm } from "@/lib/iyzico";
export type OdemeBaslatDurum = { error?: string } | undefined;
export async function baslatOdeme(
_prev: OdemeBaslatDurum,
formData: FormData,
): Promise<OdemeBaslatDurum> {
const session = await verifySession("/paket");
const product = formData.get("urun");
if (product !== "paket" && product !== "topup") {
return { error: "Geçersiz ürün." };
}
const urun = URUNLER[product];
const orderId = crypto.randomUUID();
await appDb.insert(schema.orders).values({
id: orderId,
userId: session.user.id,
product,
amountKurus: urun.amountKurus,
credits: urun.credits,
status: "pending",
createdAt: new Date(),
});
const h = await headers();
const buyerIp =
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "85.34.78.112";
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
let init;
try {
init = await initializeCheckoutForm({
orderId,
fiyatKurus: urun.amountKurus,
urunAdi: `KolayTercih — ${urun.label}`,
email: session.user.email,
userId: session.user.id,
callbackUrl: `${appUrl}/api/odeme/callback`,
buyerIp,
});
} catch (err) {
if (err instanceof Error && err.message === "IYZICO_KEYS_MISSING") {
return {
error:
"Ödeme altyapısı henüz yapılandırılmadı (iyzico anahtarları eksik).",
};
}
throw err;
}
if (init.status !== "success" || !init.paymentPageUrl || !init.token) {
return {
error: init.errorMessage ?? "Ödeme başlatılamadı. Tekrar dener misin?",
};
}
await appDb
.update(schema.orders)
.set({ iyzicoToken: init.token })
.where(eq(schema.orders.id, orderId));
redirect(init.paymentPageUrl);
}

157
src/app/paket/page.tsx Normal file
View File

@@ -0,0 +1,157 @@
import Link from "next/link";
import type { Metadata } from "next";
import {
CheckCircle2,
Coins,
FileText,
ListChecks,
MessageCircleQuestion,
RefreshCcw,
ShieldCheck,
} from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SatinAlForm } from "./satin-al-form";
export const metadata: Metadata = {
title: "Tercih Dönemi Paketi — KolayTercih",
};
function tl(kurus: number) {
return (kurus / 100).toLocaleString("tr-TR", { maximumFractionDigits: 0 });
}
const OZELLIKLER = [
{
icon: ListChecks,
text: "Sıralamana özel, gerekçeli 24 tercihlik dengeli liste",
},
{
icon: ShieldCheck,
text: "Her tercih için yerleşme olasılığı ve risk analizi (son 4 yıl trend)",
},
{ icon: FileText, text: "Veliyle paylaşılabilir PDF rapor" },
{ icon: RefreshCcw, text: "2 liste revizyon hakkı" },
{
icon: MessageCircleQuestion,
text: `${URUNLER.paket.credits} AI danışman sorusu (her mesaj 1 kredi)`,
},
] as const;
export default async function PaketPage() {
const u = await getCurrentUser();
return (
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
<div className="text-center">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Tek seferlik ödeme · Abonelik yok
</Badge>
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
İnsan danışmanın işini,{" "}
<span className="text-primary">onda bir fiyatına</span>
</h1>
<p className="mx-auto mt-3 max-w-xl text-slate-600">
Tercih danışmanları bu dönemde 2.00010.000 TL alıyor. KolayTercih
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
</p>
</div>
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]">
{/* Ana paket */}
<section className="rounded-2xl border-2 border-orange-500 bg-white p-8 shadow-sm">
<div className="flex items-baseline justify-between">
<h2 className="font-heading text-xl font-bold">
Tercih Dönemi Paketi
</h2>
<p className="font-heading text-3xl font-bold">
{tl(URUNLER.paket.amountKurus)} TL
</p>
</div>
<ul className="mt-6 space-y-3">
{OZELLIKLER.map((o) => (
<li key={o.text} className="flex items-start gap-3 text-sm">
<o.icon
className="mt-0.5 size-4 shrink-0 text-emerald-600"
aria-hidden
/>
{o.text}
</li>
))}
</ul>
<div className="mt-8">
{u ? (
u.hasPaket ? (
<div className="flex items-center gap-2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
<CheckCircle2 className="size-4" aria-hidden />
Paketin aktif {" "}
<Link href="/" className="underline">
listeni oluştur
</Link>
</div>
) : (
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
/>
)
) : (
<Button
asChild
className="h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}>
Giriş yap ve {DENEME_KREDISI} deneme kredisi kazan
</Link>
</Button>
)}
</div>
<p className="mt-4 text-center text-xs text-slate-500">
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz
</p>
</section>
{/* Top-up */}
<section className="flex h-fit flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:w-56">
<div className="flex items-center gap-2">
<Coins className="size-4 text-amber-500" aria-hidden />
<h2 className="font-heading font-bold">Kredi bitti mi?</h2>
</div>
<p className="mt-2 flex-1 text-sm text-slate-600">
+{URUNLER.topup.credits} soru hakkı,{" "}
{tl(URUNLER.topup.amountKurus)} TL.
</p>
<div className="mt-4">
{u ? (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi al`}
/>
) : (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer"
>
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}>
Önce giriş yap
</Link>
</Button>
)}
</div>
</section>
</div>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine
dayanır ancak yerleşme garantisi verilmez. Tercih listesinin nihai
sorumluluğu adaya aittir.
</p>
</main>
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
export function SatinAlForm({
urun,
label,
vurgulu = false,
}: {
urun: "paket" | "topup";
label: string;
vurgulu?: boolean;
}) {
const [state, formAction, pending] = useActionState<
OdemeBaslatDurum,
FormData
>(baslatOdeme, undefined);
useEffect(() => {
if (state?.error) toast.error(state.error);
}, [state]);
return (
<form action={formAction}>
<input type="hidden" name="urun" value={urun} />
<Button
type="submit"
disabled={pending}
className={
vurgulu
? "h-12 w-full cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
: "h-11 w-full cursor-pointer"
}
variant={vurgulu ? "default" : "outline"}
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
{pending ? "Ödeme sayfasına yönlendiriliyorsun…" : label}
</Button>
</form>
);
}

View File

@@ -0,0 +1,110 @@
import type { Metadata } from "next";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { verifySession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI } from "@/lib/db";
import type { RaporSonuc } from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { YazdirButonu } from "./yazdir-butonu";
export const metadata: Metadata = {
title: "Tercih Raporu (Yazdır) — KolayTercih",
};
const DILIM_LABEL: Record<string, string> = {
hayal: "Hayal",
dengeli: "Dengeli",
garanti: "Garanti",
};
export default async function YazdirPage() {
await verifySession("/rapor/yazdir");
const user = await getCurrentUser();
if (!user) redirect("/giris");
if (!user.hasPaket) redirect("/paket");
const satir = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
if (!satir?.result) redirect("/sonuc");
const rapor = satir.result as RaporSonuc;
const params = satir.params as RaporParams;
const tarih = new Date(satir.updatedAt).toLocaleDateString("tr-TR", {
day: "numeric",
month: "long",
year: "numeric",
});
return (
<div className="mx-auto max-w-3xl bg-white p-8 text-slate-900 print:p-0">
<YazdirButonu />
<header className="border-b-2 border-slate-900 pb-4">
<h1 className="font-heading text-2xl font-bold">
KolayTercih 24 Tercihlik Kişisel Liste ve Risk Raporu
</h1>
<p className="mt-1 text-sm text-slate-600">
Başarı sıralaması: {params.sira.toLocaleString("tr-TR")} · Puan
türü: {PUAN_TURLERI[params.tur]}
{params.il ? ` · İl: ${params.il}` : ""} · {tarih}
</p>
</header>
<section className="mt-5">
<h2 className="font-heading text-base font-bold">
Genel değerlendirme
</h2>
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed">
{rapor.genelDegerlendirme}
</p>
</section>
<table className="mt-5 w-full border-collapse text-xs">
<thead>
<tr className="border-b-2 border-slate-900 text-left">
<th className="py-2 pr-2">#</th>
<th className="py-2 pr-2">Program / Üniversite</th>
<th className="py-2 pr-2">Dilim</th>
<th className="py-2 pr-2">Gerekçe</th>
<th className="py-2 pr-2">Risk notu</th>
<th className="py-2">Trend</th>
</tr>
</thead>
<tbody>
{rapor.tercihler.map((t) => {
const p = rapor.programlar[t.programId];
return (
<tr
key={t.sira}
className="break-inside-avoid border-b border-slate-200 align-top"
>
<td className="py-2 pr-2 font-bold">{t.sira}</td>
<td className="py-2 pr-2">
<span className="font-semibold">{p?.isim}</span>
<br />
<span className="text-slate-600">
{p?.universite} · {p?.il ?? "—"}
</span>
</td>
<td className="py-2 pr-2">{DILIM_LABEL[t.dilim]}</td>
<td className="py-2 pr-2">{t.gerekce}</td>
<td className="py-2 pr-2">{t.riskNotu}</td>
<td className="py-2">{t.trendOzeti}</td>
</tr>
);
})}
</tbody>
</table>
<footer className="mt-6 border-t border-slate-300 pt-3 text-[10px] leading-relaxed text-slate-500">
Bu rapor KolayTercih tarafından resmî YÖK Atlas verisi (20212025 taban
sıralamaları, kontenjan ve yerleşme istatistikleri) kullanılarak
üretilmiştir. Taban sıralamaları her yıl değişir; bu rapor yerleşme
garantisi vermez. Tercih listesinin ÖSYM sistemine girilmesi ve nihai
sorumluluğu adaya aittir.
</footer>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
export function YazdirButonu() {
return (
<div className="mb-6 flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 print:hidden">
<p className="text-sm text-slate-600">
&quot;PDF olarak kaydet&quot; seçeneğiyle raporu indirebilir veya
yazdırabilirsin.
</p>
<Button
onClick={() => window.print()}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Printer className="size-4" aria-hidden />
Yazdır / PDF kaydet
</Button>
</div>
);
}

252
src/app/sonuc/actions.ts Normal file
View File

@@ -0,0 +1,252 @@
"use server";
import { eq, sql } from "drizzle-orm";
import { getSession, getCurrentUser } from "@/lib/session";
import { appDb, schema } from "@/lib/appdb";
import { PUAN_TURLERI, type PuanTuruKey } from "@/lib/db";
import { spendCredits, grantCredits, RAPOR_KREDI } from "@/lib/credits";
import {
raporUret,
listeOzetiCikar,
RaporUretimHatasi,
type RaporSonuc,
} from "@/lib/ai/rapor";
import type { RaporParams } from "@/lib/rapor-havuzu";
import { sihirbazDogrula, type SihirbazSecimleri } from "@/lib/sihirbaz";
const MAX_REVIZYON = 2;
// Modal yerinde render ettiği için action redirect etmez; sonucu döner.
export type ListeSonuc =
| {
ok: true;
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
kredi: number;
}
| { ok: false; error: string; code: "AUTH" | "PAKET" | "KREDI" | "HATA" };
function siraTurGecerli(sira: number, tur: string): tur is PuanTuruKey {
return (
Number.isFinite(sira) && sira >= 1 && sira <= 4_000_000 && tur in PUAN_TURLERI
);
}
function aiHataMesaji(err: unknown): string {
if (err instanceof RaporUretimHatasi) return err.message;
if (err instanceof Error && err.message === "AI_KEY_MISSING") {
return "AI altyapısı henüz yapılandırılmadı (ANTHROPIC_API_KEY eksik).";
}
console.error("[liste] üretim hatası:", err);
return "Liste üretilirken bir sorun oluştu. Birazdan tekrar dener misin?";
}
function paramsBirlestir(
sira: number,
tur: PuanTuruKey,
secimler: SihirbazSecimleri,
): RaporParams {
return {
sira: Math.round(sira),
tur,
kategoriler: secimler.kategoriler,
iller: secimler.iller,
universiteTipi: secimler.universiteTipi,
oncelikler: secimler.oncelikler,
};
}
export async function listeOlustur(input: {
sira: number;
tur: string;
secimler: unknown;
requestId: string;
}): Promise<ListeSonuc> {
const session = await getSession();
if (!session) {
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
}
const user = await getCurrentUser();
if (!user) {
return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
}
if (!user.hasPaket) {
return { ok: false, code: "PAKET", error: "Paket gerekli." };
}
if (!siraTurGecerli(input.sira, input.tur)) {
return { ok: false, code: "HATA", error: "Geçerli bir sıralama gerekli." };
}
const secimler = sihirbazDogrula(input.secimler);
if (!secimler) {
return {
ok: false,
code: "HATA",
error: "En az bir ilgi alanı seçmelisin.",
};
}
if (!input.requestId || typeof input.requestId !== "string") {
return { ok: false, code: "HATA", error: "Geçersiz istek." };
}
const params = paramsBirlestir(input.sira, input.tur, secimler);
// 3 kredi düş (idempotent: aynı requestId ile retry çifte harcamaz)
const harcama = await spendCredits({
userId: user.id,
amount: RAPOR_KREDI,
reason: "report_generate",
refId: input.requestId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
return {
ok: false,
code: "KREDI",
error: `Liste oluşturmak için ${RAPOR_KREDI} kredi gerekli.`,
};
}
// DUPLICATE: bu requestId zaten işlendi → mevcut raporu döndür
const mevcut = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
if (mevcut?.result) {
return {
ok: true,
rapor: mevcut.result as RaporSonuc,
params: mevcut.params as RaporParams,
revisionCount: mevcut.revisionCount,
kredi: user.creditBalance,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
}
let sonuc: RaporSonuc;
try {
sonuc = await raporUret(params);
} catch (err) {
// Üretim başarısız → krediyi iade et
await grantCredits({
userId: user.id,
delta: RAPOR_KREDI,
reason: "refund",
refId: input.requestId,
}).catch(() => {});
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
}
const now = new Date();
await appDb
.insert(schema.reports)
.values({
id: crypto.randomUUID(),
userId: user.id,
params,
result: sonuc,
revisionCount: 0,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: schema.reports.userId,
set: { params, result: sonuc, revisionCount: 0, updatedAt: now },
});
return {
ok: true,
rapor: sonuc,
params,
revisionCount: 0,
kredi: user.creditBalance - RAPOR_KREDI,
};
}
export async function listeRevize(input: {
feedback: string;
requestId: string;
}): Promise<ListeSonuc> {
const session = await getSession();
if (!session) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
const user = await getCurrentUser();
if (!user) return { ok: false, code: "AUTH", error: "Giriş yapmalısın." };
if (!user.hasPaket) return { ok: false, code: "PAKET", error: "Paket gerekli." };
const feedback = (input.feedback ?? "").trim();
if (feedback.length < 5) {
return {
ok: false,
code: "HATA",
error: "Ne değişsin istediğini kısaca yazmalısın.",
};
}
if (!input.requestId) {
return { ok: false, code: "HATA", error: "Geçersiz istek." };
}
const rapor = await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
});
if (!rapor?.result) {
return { ok: false, code: "HATA", error: "Önce bir liste oluşturmalısın." };
}
if (rapor.revisionCount >= MAX_REVIZYON) {
return {
ok: false,
code: "HATA",
error: `Revizyon hakkın doldu (en fazla ${MAX_REVIZYON}).`,
};
}
const params = rapor.params as RaporParams;
const onceki = rapor.result as RaporSonuc;
const harcama = await spendCredits({
userId: user.id,
amount: RAPOR_KREDI,
reason: "report_revision",
refId: input.requestId,
});
if (!harcama.ok) {
if (harcama.error === "INSUFFICIENT") {
return {
ok: false,
code: "KREDI",
error: `Revizyon için ${RAPOR_KREDI} kredi gerekli.`,
};
}
return { ok: false, code: "HATA", error: "İstek tekrarlandı." };
}
let sonuc: RaporSonuc;
try {
sonuc = await raporUret(params, feedback.slice(0, 1000), listeOzetiCikar(onceki));
} catch (err) {
await grantCredits({
userId: user.id,
delta: RAPOR_KREDI,
reason: "refund",
refId: input.requestId,
}).catch(() => {});
return { ok: false, code: "HATA", error: aiHataMesaji(err) };
}
// Sayaç yalnızca başarılı üretimde artar
await appDb
.update(schema.reports)
.set({
result: sonuc,
revisionCount: sql`${schema.reports.revisionCount} + 1`,
updatedAt: new Date(),
})
.where(eq(schema.reports.id, rapor.id));
return {
ok: true,
rapor: sonuc,
params,
revisionCount: rapor.revisionCount + 1,
kredi: user.creditBalance - RAPOR_KREDI,
};
}

View File

@@ -7,17 +7,27 @@ import {
Rocket,
Scale,
ShieldCheck,
Sparkles,
TrendingDown,
TrendingUp,
X,
} 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";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -31,7 +41,7 @@ import {
} from "@/components/ui/table";
export const metadata: Metadata = {
title: "Sıralamana Uygun Programlar — TercihAI",
title: "Sıralamana Uygun Programlar — KolayTercih",
};
const TUR_LABELS: Record<PuanTuruKey, string> = {
@@ -138,7 +148,12 @@ function ProgramTable({ programs }: { programs: Program[] }) {
export default async function SonucPage({
searchParams,
}: {
searchParams: Promise<{ sira?: string; tur?: string; il?: string }>;
searchParams: Promise<{
sira?: string;
tur?: string;
il?: string;
sihirbaz?: string;
}>;
}) {
const params = await searchParams;
const sira = Number.parseInt(params.sira ?? "", 10);
@@ -167,6 +182,35 @@ export default async function SonucPage({
);
}
// Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler
const session = await getSession();
const user = session ? await getCurrentUser() : null;
const raporSatiri = user
? await appDb.query.reports.findFirst({
where: eq(schema.reports.userId, user.id),
})
: null;
const facetler = rankWindowFacets(sira, turKey);
const mevcutRapor: MevcutRapor | null = raporSatiri?.result
? {
rapor: raporSatiri.result as RaporSonuc,
params: raporSatiri.params as RaporParams,
revisionCount: raporSatiri.revisionCount,
}
: null;
// Paketsiz kullanıcıya blur önizlemede gösterilecek gerçek dengeli satırlar
const onizlemeSatirlari: OnizlemeSatir[] = searchByRank(sira, turKey, {
limitPerBucket: 5,
})
.dengeli.slice(0, 5)
.map((p) => ({
id: p.id,
isim: p.isim,
universite: p.universite,
il: p.il,
}));
const results = searchByRank(sira, turKey, {
il: seciliIl ?? undefined,
});
@@ -220,28 +264,25 @@ export default async function SonucPage({
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="sticky top-4 z-50 mx-4">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between rounded-2xl border border-slate-200 bg-white/80 px-5 shadow-sm backdrop-blur">
<Link
href="/"
className="flex items-center gap-2 font-heading text-lg font-bold"
<main
className={`mx-auto max-w-6xl px-4 py-12 ${mevcutRapor ? "" : "pb-56"}`}
>
<div className="flex items-center justify-between gap-3">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Ücretsiz ön izleme
</Badge>
<Button
asChild
size="sm"
variant="outline"
className="cursor-pointer rounded-full"
>
<Sparkles className="size-5 text-primary" aria-hidden />
TercihAI
</Link>
<Button asChild size="sm" variant="outline" className="cursor-pointer">
<Link href="/">
<ArrowLeft className="size-4" aria-hidden />
Yeni arama
</Link>
</Button>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-12">
<Badge variant="secondary" className="bg-primary/10 text-primary">
Ücretsiz ön izleme
</Badge>
<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
</h1>
@@ -336,27 +377,21 @@ export default async function SonucPage({
))}
</div>
{/* Ücretli katman CTA */}
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
Bu listeyi 24 tercihlik plana çevirelim mi?
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
Yapay zekâ danışman; şehir, bölüm ve kariyer hedeflerine göre bu
dilimlerden gerekçeli, dengeli bir tercih listesi kurar. Çok
yakında.
</p>
<Button
size="lg"
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
disabled
>
AI danışman yakında
</Button>
</section>
{/* Ücretli katman — sihirbaz akışı (CTA + alt kart + modal) */}
<SihirbazBolumu
sira={sira}
tur={turKey}
facetler={facetler}
girisliMi={Boolean(session)}
hasPaket={Boolean(user?.hasPaket)}
kredi={user?.creditBalance ?? 0}
mevcutRapor={mevcutRapor}
onizlemeSatirlari={onizlemeSatirlari}
otomatikAc={params.sihirbaz === "1"}
/>
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500">
Veriler resmî YÖK Atlas kaynağından derlenmiştir. TercihAI bir karar
Veriler resmî YÖK Atlas kaynağından derlenmiştir. KolayTercih bir karar
destek aracıdır; yerleşme garantisi vermez, tercih sorumluluğu adaya
aittir.
</p>

View File

@@ -0,0 +1,201 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { SihirbazFacetleri } from "@/lib/db";
import {
SIHIRBAZ_STORAGE_KEY,
sihirbazDogrula,
type SihirbazSecimleri,
} from "@/lib/sihirbaz";
import {
SihirbazModal,
type MevcutRapor,
type OnizlemeSatir,
} from "./sihirbaz-modal";
const KAPATMA_KEY = "kolaytercih.sihirbaz-karti-kapali";
// SSR'da false, client'ta true — storage'a ancak mount sonrası dokunuruz
const bosAbone = () => () => {};
const useMonteEdildi = () =>
useSyncExternalStore(
bosAbone,
() => true,
() => false,
);
type BekleyenSecim = {
secimler: SihirbazSecimleri;
eslesir: boolean; // storage'daki sıra/tur bu sayfayla aynı mı
};
/**
* Sonuç sayfasında sihirbaz akışını yöneten client wrapper:
* - Sayfa ortasındaki büyük CTA (satır içi)
* - Sabit alt çağrı kartı
* - Modal
* hepsi tek `acik` state'ini paylaşır.
*/
export function SihirbazBolumu({
sira,
tur,
facetler,
girisliMi,
hasPaket,
kredi,
mevcutRapor,
onizlemeSatirlari,
otomatikAc,
}: {
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** ?sihirbaz=1 ile gelindiyse modalı otomatik aç */
otomatikAc?: boolean;
}) {
const monteEdildi = useMonteEdildi();
const [elleAcik, setElleAcik] = useState(false);
const [elleKapandi, setElleKapandi] = useState(false);
const [kartGizle, setKartGizle] = useState(false);
const [bekleyen, setBekleyen] = useState<BekleyenSecim | null>(null);
// Mount sonrası storage'dan bekleyen seçimleri + kalıcı kart kapatmayı oku
// (dış store'dan başlangıç senkronizasyonu — bir kez).
useEffect(() => {
try {
const kaliciKapali = Boolean(sessionStorage.getItem(KAPATMA_KEY));
const ham = localStorage.getItem(SIHIRBAZ_STORAGE_KEY);
let yeniBekleyen: BekleyenSecim | null = null;
if (ham) {
const veri = JSON.parse(ham) as {
sira?: number;
tur?: string;
secimler?: unknown;
};
const secimler = sihirbazDogrula(veri.secimler);
if (secimler) {
yeniBekleyen = {
secimler,
eslesir: veri.sira === sira && veri.tur === tur,
};
}
}
// eslint-disable-next-line react-hooks/set-state-in-effect
if (kaliciKapali) setKartGizle(true);
if (yeniBekleyen) setBekleyen(yeniBekleyen);
} catch {}
}, [sira, tur]);
// Giriş/ödeme dönüşünde otomatik üretim: eşleşen bekleyen seçim + paket
const otomatikUret = Boolean(bekleyen?.eslesir && girisliMi && hasPaket);
// Açık durumu türetilmiş: elle veya otomatik tetik, kullanıcı kapatmadıkça
const acik = !elleKapandi && (elleAcik || Boolean(otomatikAc) || otomatikUret);
const baslangicSecimler = bekleyen?.eslesir ? bekleyen.secimler : null;
function acikDegisti(v: boolean) {
if (v) {
setElleAcik(true);
setElleKapandi(false);
} else {
setElleKapandi(true);
setElleAcik(false);
}
}
// Üretim başladıysa storage'ı temizle (modal artık state'te tutuyor)
useEffect(() => {
if (acik && otomatikUret) {
try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {}
}
}, [acik, otomatikUret]);
const raporVar = Boolean(mevcutRapor);
return (
<>
{/* Sayfa ortasındaki büyük CTA */}
<section className="mt-16 rounded-2xl bg-primary p-8 text-center text-white sm:p-12">
<h2 className="font-heading text-2xl font-bold sm:text-3xl">
{raporVar
? "24 tercihlik listen hazır"
: "Bu listeyi 24 tercihlik plana çevirelim mi?"}
</h2>
<p className="mx-auto mt-3 max-w-xl text-white/90">
{raporVar
? "Listeni görüntüle, revize et veya listen hakkında soru sor."
: "Birkaç adımda ilgi alanların ve şehir tercihine göre gerekçeli, dengeli bir tercih listesi kuralım — her satır için yerleşme riski analiziyle."}
</p>
<Button
size="lg"
onClick={() => acikDegisti(true)}
className="mt-6 h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Sparkles className="size-4" aria-hidden />
{raporVar ? "Listemi görüntüle" : "Listemi oluştur"}
</Button>
</section>
{/* Sabit alt çağrı kartı — rapor yoksa ve kart kapatılmadıysa */}
{monteEdildi && !raporVar && !kartGizle && !acik ? (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4">
<div className="pointer-events-auto mx-auto flex max-w-xl items-center gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl">
<div className="flex-1">
<p className="font-heading text-sm font-bold text-slate-900">
3 adımda 24 tercihlik listen
</p>
<p className="text-xs text-slate-500">
Sıralamana uygun bölümleri seç, yapay zekâ listeni kursun.
</p>
</div>
<Button
size="sm"
onClick={() => acikDegisti(true)}
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
Başla
</Button>
<button
type="button"
aria-label="Kapat"
onClick={() => {
setKartGizle(true);
try {
sessionStorage.setItem(KAPATMA_KEY, "1");
} catch {}
}}
className="cursor-pointer rounded-full p-1 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
>
<X className="size-4" aria-hidden />
</button>
</div>
</div>
) : null}
{monteEdildi ? (
<SihirbazModal
acik={acik}
onAcikDegisti={acikDegisti}
sira={sira}
tur={tur}
facetler={facetler}
girisliMi={girisliMi}
hasPaket={hasPaket}
kredi={kredi}
mevcutRapor={mevcutRapor}
onizlemeSatirlari={onizlemeSatirlari}
otomatikUret={otomatikUret}
baslangicSecimler={baslangicSecimler}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,636 @@
"use client";
import { useEffect, useRef, useState } from "react";
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";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { RaporListesi } from "@/components/rapor-listesi";
import { SoruKutusu } from "./soru-kutusu";
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";
const MAX_REVIZYON = 2;
export type OnizlemeSatir = {
id: string;
isim: string;
universite: string;
il: string | null;
};
export type MevcutRapor = {
rapor: RaporSonuc;
params: RaporParams;
revisionCount: number;
};
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…",
"Her tercih için risk ve trend analizi yazılıyor…",
"Son rötuşlar yapılıyor, neredeyse hazır…",
];
export function SihirbazModal({
acik,
onAcikDegisti,
sira,
tur,
facetler,
girisliMi,
hasPaket,
kredi: baslangicKredi,
mevcutRapor,
onizlemeSatirlari,
otomatikUret,
baslangicSecimler,
}: {
acik: boolean;
onAcikDegisti: (acik: boolean) => void;
sira: number;
tur: string;
facetler: SihirbazFacetleri;
girisliMi: boolean;
hasPaket: boolean;
kredi: number;
mevcutRapor: MevcutRapor | null;
onizlemeSatirlari: OnizlemeSatir[];
/** Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla */
otomatikUret?: boolean;
baslangicSecimler?: SihirbazSecimleri | null;
}) {
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 ?? [],
);
// 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");
setUretimMesajIdx(0);
const interval = setInterval(() => {
setUretimMesajIdx((i) => Math.min(i + 1, URETIM_MESAJLARI.length - 1));
}, 7000);
try {
const sonuc = await listeOlustur({
sira,
tur,
secimler,
requestId: requestIdRef.current,
});
if (sonuc.ok) {
requestIdRef.current = null;
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
setFaz("liste");
return;
}
// Hata: aynı requestId'yi koru ki tekrar denemede çifte harcama olmasın
if (sonuc.code === "KREDI") {
toast.error(sonuc.error);
setFaz("adimlar");
} else if (sonuc.code === "PAKET") {
setFaz("onizleme");
} else if (sonuc.code === "AUTH") {
secimleriKaydetVeGirise(secimler);
} else {
toast.error(sonuc.error);
setFaz("adimlar");
}
} catch {
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
setFaz("adimlar");
} finally {
clearInterval(interval);
}
}
function secimleriKaydetVeGirise(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
const geri = `/sonuc?sira=${sira}&tur=${tur}&sihirbaz=1`;
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
}
// Giriş dönüşü: storage'daki seçimlerle otomatik üretime başla (bir kez)
useEffect(() => {
if (
otomatikUret &&
baslangicSecimler &&
hasPaket &&
girisliMi &&
!otomatikBasladi.current &&
!mevcutRapor
) {
otomatikBasladi.current = true;
void uret(baslangicSecimler);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Paketsiz kullanıcı önizlemeye geçerken seçimleri sakla ki ödeme dönüşünde
// (aynı sıralamayla) sihirbaz kaldığı yerden devam edebilsin.
function secimleriSakla(secimler: SihirbazSecimleri) {
try {
localStorage.setItem(
SIHIRBAZ_STORAGE_KEY,
JSON.stringify({ sira, tur, secimler }),
);
} catch {}
}
function ilerle() {
const secimler = secimleriTopla();
if (!girisliMi) {
secimleriKaydetVeGirise(secimler);
return;
}
if (!hasPaket) {
secimleriSakla(secimler);
setFaz("onizleme");
return;
}
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"
}
/>
))}
<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 = (
<div>
<h3 className="font-heading text-xl font-bold">
24 tercihlik listen hazır olmak üzere
</h3>
<p className="mt-2 text-sm text-slate-600">
Aşağıda ilk birkaç satırın ön izlemesi var. Tam listeyi, gerekçeleri, risk
analizini ve PDF çıktıyı görmek için paketi aktive et.
</p>
<div className="mt-6 overflow-hidden rounded-2xl border border-slate-200">
{onizlemeSatirlari.length > 0 ? (
<ul className="divide-y divide-slate-100">
{onizlemeSatirlari.map((p, i) => (
<li key={p.id} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-400">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{p.isim}</p>
<p className="truncate text-sm text-slate-500">
{p.universite} · {p.il ?? "—"}
</p>
</div>
<Badge variant="secondary" className="bg-blue-100 text-blue-700">
Dengeli
</Badge>
</li>
))}
</ul>
) : null}
<div className="relative">
<ul className="divide-y divide-slate-100 blur-sm select-none" aria-hidden>
{Array.from({ length: 7 }).map((_, i) => (
<li key={i} className="flex items-center gap-4 px-5 py-3">
<span className="w-6 text-right font-heading font-bold text-slate-300">
{onizlemeSatirlari.length + i + 1}
</span>
<div className="flex-1 space-y-1.5">
<div className="h-3.5 w-2/3 rounded bg-slate-200" />
<div className="h-3 w-1/2 rounded bg-slate-100" />
</div>
<div className="h-5 w-16 rounded-full bg-slate-100" />
</li>
))}
</ul>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-white/30 via-white/80 to-white">
<Lock className="size-8 text-slate-400" aria-hidden />
<p className="max-w-sm text-center text-sm font-medium text-slate-700">
+19 tercih, her satır için gerekçe ve risk analizi, PDF çıktı ve AI
soru hakkı seni bekliyor.
</p>
<Button
asChild
size="lg"
className="h-12 cursor-pointer bg-orange-500 px-8 text-white transition-colors duration-200 hover:bg-orange-600"
>
<Link href="/paket">299 TL Paketi aktive et</Link>
</Button>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
onClick={() => setFaz("adimlar")}
className="mt-4 cursor-pointer"
>
<ArrowLeft className="size-4" aria-hidden />
Seçimlere dön
</Button>
</div>
);
const uretiliyorGovde = (
<div className="flex flex-col items-center justify-center gap-4 py-16 text-center">
<Loader2 className="size-10 animate-spin text-primary" aria-hidden />
<p className="font-heading text-lg font-bold">Listen hazırlanıyor</p>
<p className="max-w-sm text-sm text-slate-500">
{URETIM_MESAJLARI[uretimMesajIdx]}
</p>
<p className="text-xs text-slate-400">
Bu işlem yarım dakika kadar sürebilir, sayfayı kapatma.
</p>
</div>
);
const listeGovde = rapor ? (
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-heading text-xl font-bold">24 Tercihlik Listen</h3>
<p className="mt-1 text-sm text-slate-500">
{sira.toLocaleString("tr-TR")}. sıra · {kredi} kredin kaldı
</p>
</div>
<Button asChild variant="outline" className="cursor-pointer">
<Link href="/rapor/yazdir" target="_blank">
<FileText className="size-4" aria-hidden />
PDF indir
</Link>
</Button>
</div>
<div className="mt-5">
<RaporListesi rapor={rapor.rapor} />
</div>
<div className="mt-6">
<RevizyonKutusu
kalanHak={MAX_REVIZYON - rapor.revisionCount}
onRevize={async (feedback) => {
const sonuc = await listeRevize({
feedback,
requestId: crypto.randomUUID(),
});
if (sonuc.ok) {
setRapor({
rapor: sonuc.rapor,
params: sonuc.params,
revisionCount: sonuc.revisionCount,
});
setKredi(sonuc.kredi);
toast.success("Listen yeniden kuruldu.");
} else {
toast.error(sonuc.error);
}
return sonuc.ok;
}}
/>
</div>
<div className="mt-6">
<SoruKutusu kredi={kredi} onKrediDegisti={setKredi} />
</div>
</div>
) : null;
return (
<Dialog open={acik} onOpenChange={onAcikDegisti}>
<DialogContent className="max-h-[92dvh] gap-0 overflow-y-auto p-6 sm:max-w-3xl">
<DialogTitle className="sr-only">Tercih listesi sihirbazı</DialogTitle>
<DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur.
</DialogDescription>
{faz === "adimlar"
? adimlarGovde
: faz === "onizleme"
? onizlemeGovde
: faz === "uretiliyor"
? uretiliyorGovde
: listeGovde}
</DialogContent>
</Dialog>
);
}
function RevizyonKutusu({
kalanHak,
onRevize,
}: {
kalanHak: number;
onRevize: (feedback: string) => Promise<boolean>;
}) {
const [feedback, setFeedback] = useState("");
const [pending, setPending] = useState(false);
if (kalanHak <= 0) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<p className="text-sm text-slate-500">
Revizyon hakların doldu. Aşağıdan listen hakkında soru sorabilirsin.
</p>
</div>
);
}
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<h3 className="font-heading text-base font-bold">
Listede değişiklik mi istiyorsun?
</h3>
<p className="mb-3 mt-1 text-sm text-slate-600">
Ne değişsin istediğini yaz; liste aynı gerçek veriyle yeniden kurulsun (3
kredi).
</p>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
rows={2}
maxLength={1000}
placeholder="ör. İlk 5 tercihte daha fazla İstanbul olsun, garanti dilimini genişlet…"
disabled={pending}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<div className="mt-3 flex items-center gap-3">
<Button
type="button"
variant="outline"
disabled={pending || feedback.trim().length < 5}
onClick={async () => {
setPending(true);
const ok = await onRevize(feedback.trim());
setPending(false);
if (ok) setFeedback("");
}}
className="cursor-pointer"
>
{pending ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCcw className="size-4" aria-hidden />
)}
{pending ? "Yeniden kuruluyor…" : "Revize et (3 kredi)"}
</Button>
<span className="text-xs text-slate-500">
Kalan hak: <span className="font-semibold">{kalanHak}</span>
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Coins, Loader2, SendHorizonal, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
}
const ORNEK_SORULAR = [
"Listemdeki ilk 5 tercih çok mu riskli?",
"Bu sıralamayla hangi bölüme kesin girerim?",
"İlk tercihimle ikinciyi neden bu sırada koydun?",
];
/**
* Liste oluşturulduktan sonra, o listeye bağlı bağlamsal soru-cevap kutusu.
* Serbest sohbet değil: her soru 1 kredi, cevap /api/soru'dan stream edilir.
*/
export function SoruKutusu({
kredi,
onKrediDegisti,
}: {
// Kredi tümüyle kontrollü: kaynak modaldaki state (tek doğruluk kaynağı)
kredi: number;
onKrediDegisti: (kredi: number) => void;
}) {
const [mesajlar, setMesajlar] = useState<Mesaj[]>([]);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
const [yuklendi, setYuklendi] = useState(false);
const altRef = useRef<HTMLDivElement>(null);
// Geçmiş soru-cevapları ve güncel krediyi yükle
useEffect(() => {
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.mesajlar) setMesajlar(data.mesajlar);
if (typeof data?.kredi === "number") onKrediDegisti(data.kredi);
})
.catch(() => {})
.finally(() => setYuklendi(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (mesajlar.length > 0) {
altRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [mesajlar]);
function krediGuncelle(yeni: number) {
onKrediDegisti(yeni);
}
async function gonder(metin: string) {
const mesaj = metin.trim();
if (!mesaj || bekliyor) return;
if (kredi < 1) {
toast.error("Kredin bitti — devam etmek için kredi yükle.");
return;
}
setGirdi("");
setBekliyor(true);
const clientMessageId = crypto.randomUUID();
const asistanId = crypto.randomUUID();
setMesajlar((m) => [
...m,
{ id: clientMessageId, role: "user", content: mesaj },
{ id: asistanId, role: "assistant", content: "" },
]);
try {
const res = await fetch("/api/soru", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: mesaj, clientMessageId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setMesajlar((m) =>
m.filter((x) => x.id !== asistanId && x.id !== clientMessageId),
);
if (res.status === 402) {
toast.error("Kredin bitti — kredi yükleyerek devam edebilirsin.");
krediGuncelle(0);
} else {
toast.error(data.error ?? "Bir sorun oluştu, tekrar dener misin?");
}
return;
}
krediGuncelle(kredi - 1);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let birikmis = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
birikmis += decoder.decode(value, { stream: true });
const anlik = birikmis;
setMesajlar((m) =>
m.map((x) => (x.id === asistanId ? { ...x, content: anlik } : x)),
);
}
} catch {
toast.error("Bağlantı koptu. Cevabı sayfayı yenileyerek görebilirsin.");
} finally {
setBekliyor(false);
}
}
return (
<div className="rounded-2xl border border-slate-200 bg-white">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 className="font-heading text-base font-bold">Listen hakkında sor</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700">
<Coins className="size-3.5" aria-hidden />
{kredi} kredi
</span>
</div>
<div className="max-h-72 space-y-3 overflow-y-auto p-4">
{mesajlar.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<Sparkles className="size-6 text-primary" aria-hidden />
<p className="max-w-sm text-sm text-slate-600">
Bu listeye özel soru sorabilirsin. Her soru 1 kredi kullanır.
</p>
{yuklendi ? (
<div className="flex flex-wrap justify-center gap-2">
{ORNEK_SORULAR.map((s) => (
<button
key={s}
onClick={() => gonder(s)}
disabled={kredi < 1}
className="cursor-pointer rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-700 transition-colors duration-200 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{s}
</button>
))}
</div>
) : null}
</div>
) : (
mesajlar.map((m) => (
<div
key={m.id}
className={
m.role === "user" ? "flex justify-end" : "flex justify-start"
}
>
<div
className={
m.role === "user"
? "max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-white"
: "max-w-[85%] whitespace-pre-line rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-2.5 text-sm text-slate-800"
}
>
{m.content || (
<Loader2
className="size-4 animate-spin text-slate-400"
aria-hidden
/>
)}
</div>
</div>
))
)}
<div ref={altRef} />
</div>
<form
className="flex gap-2 border-t border-slate-100 p-3"
onSubmit={(e) => {
e.preventDefault();
gonder(girdi);
}}
>
<Input
value={girdi}
onChange={(e) => setGirdi(e.target.value)}
placeholder={
kredi < 1 ? "Kredin bitti" : "Sorunu yaz… (1 kredi)"
}
disabled={bekliyor || kredi < 1}
maxLength={2000}
className="h-11 flex-1"
/>
{kredi < 1 ? (
<Button
asChild
className="h-11 cursor-pointer bg-orange-500 px-4 text-white hover:bg-orange-600"
>
<Link href="/paket">Kredi yükle</Link>
</Button>
) : (
<Button
type="submit"
disabled={bekliyor || !girdi.trim()}
className="h-11 cursor-pointer bg-orange-500 px-4 text-white transition-colors duration-200 hover:bg-orange-600"
aria-label="Gönder"
>
{bekliyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<SendHorizonal className="size-4" aria-hidden />
)}
</Button>
)}
</form>
</div>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import { usePathname } from "next/navigation";
// Header'ın gizleneceği rotalar (tam eşleşme veya alt yol)
const HIDDEN_PREFIXES = ["/giris"];
export function HeaderGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const gizli = HIDDEN_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
if (gizli) return null;
return <>{children}</>;
}

View File

@@ -0,0 +1,83 @@
import { Rocket, Scale, ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { RaporSonuc } from "@/lib/ai/rapor";
export const DILIM_STIL: Record<
string,
{ label: string; cls: string; icon: typeof Rocket }
> = {
hayal: { label: "Hayal", cls: "bg-orange-100 text-orange-700", icon: Rocket },
dengeli: { label: "Dengeli", cls: "bg-blue-100 text-blue-700", icon: Scale },
garanti: {
label: "Garanti",
cls: "bg-emerald-100 text-emerald-700",
icon: ShieldCheck,
},
};
/**
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
* Hem sonuç modalında hem PDF/yazdır sayfasında kullanılır.
*/
export function RaporListesi({ rapor }: { rapor: RaporSonuc }) {
return (
<div>
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h2 className="font-heading text-lg font-bold">Genel değerlendirme</h2>
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700">
{rapor.genelDegerlendirme}
</p>
</div>
<ol className="mt-6 space-y-3">
{rapor.tercihler.map((t) => {
const p = rapor.programlar[t.programId];
const stil = DILIM_STIL[t.dilim];
return (
<li
key={t.sira}
className="rounded-2xl border border-slate-200 bg-white p-5"
>
<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">
{t.sira}
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold">{p?.isim ?? t.programId}</p>
<p className="text-sm text-slate-500">
{p?.universite} · {p?.il ?? "—"}
{p?.unitur ? ` · ${p.unitur}` : ""}
</p>
</div>
<Badge variant="secondary" className={stil.cls}>
<stil.icon className="size-3" aria-hidden />
{stil.label}
</Badge>
</div>
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Neden listede?
</dt>
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
Risk notu
</dt>
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
</div>
<div className="rounded-lg bg-slate-50 p-3">
<dt className="text-xs font-semibold text-slate-500">
4 yıllık trend
</dt>
<dd className="mt-1 text-slate-700">{t.trendOzeti}</dd>
</div>
</dl>
</li>
);
})}
</ol>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignOutButton() {
const router = useRouter();
return (
<Button
size="sm"
variant="ghost"
className="size-12 cursor-pointer rounded-full border border-slate-200 bg-white text-slate-600 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
aria-label=ıkış yap"
onClick={async () => {
await authClient.signOut();
router.refresh();
}}
>
<LogOut className="size-5" aria-hidden />
</Button>
);
}

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
import { GraduationCap } from "lucide-react";
import { UserNav } from "@/components/user-nav";
const navLinks = [
{ href: "/#nasil-calisir", label: "Nasıl çalışır?" },
{ href: "/#karsilastirma", label: "Karşılaştır" },
{ href: "/#sss", label: "SSS" },
];
export function SiteHeader() {
return (
<header className="sticky top-6 z-50 mt-6 print:hidden">
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-3 px-4">
<Link
href="/"
className="group flex items-center gap-3 font-heading text-xl font-bold"
>
<span className="flex size-11 items-center justify-center rounded-full bg-primary text-white transition-transform duration-200 group-hover:-rotate-6">
<GraduationCap className="size-6" aria-hidden />
</span>
KolayTercih
</Link>
<nav className="hidden items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 sm:flex">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
{link.label}
</Link>
))}
</nav>
<UserNav />
</div>
</header>
);
}

View File

@@ -0,0 +1,104 @@
import React from "react";
type ProgressiveBlurProps = {
className?: string;
backgroundColor?: string;
position?: "top" | "bottom";
height?: string;
blurAmount?: string;
};
const ProgressiveBlur = ({
className = "",
backgroundColor = "#f5f4f3",
position = "top",
height = "150px",
blurAmount = "4px",
}: ProgressiveBlurProps) => {
const isTop = position === "top";
return (
<div
className={`pointer-events-none absolute left-0 w-full select-none ${className}`}
style={{
[isTop ? "top" : "bottom"]: 0,
height,
background: isTop
? `linear-gradient(to top, transparent, ${backgroundColor})`
: `linear-gradient(to bottom, transparent, ${backgroundColor})`,
maskImage: isTop
? `linear-gradient(to bottom, ${backgroundColor} 50%, transparent)`
: `linear-gradient(to top, ${backgroundColor} 50%, transparent)`,
WebkitBackdropFilter: `blur(${blurAmount})`,
backdropFilter: `blur(${blurAmount})`,
WebkitUserSelect: "none",
userSelect: "none",
}}
/>
);
};
const Skiper41 = () => {
return (
<div className="relative flex h-full w-full flex-col items-center justify-center bg-[#f5f4f3] text-black/40">
<ProgressiveBlur position="top" backgroundColor="#f5f4f3" />
<ProgressiveBlur position="bottom" backgroundColor="#f5f4f3" />
<div className="flex h-[calc(100vh-1rem)] w-full flex-col items-center overflow-scroll">
<div className="mt-42 grid content-start justify-items-center gap-6 text-center text-black">
<span className="relative max-w-[12ch] text-xs uppercase leading-tight opacity-40 after:absolute after:left-1/2 after:top-full after:h-16 after:w-px after:bg-gradient-to-b after:from-white after:to-black after:content-['']">
Scroll down to see the effect
</span>
</div>
<div className="mt-24 w-full max-w-lg space-y-20 px-5 text-justify">
{Array.from({ length: 10 }).map((_, index) => (
<div key={index}>
Lorem ipsum dolor sit amet consectetur adipisicing elit.
Obcaecati, reiciendis eum vitae nostrum, temporibus repudiandae
voluptatibus, natus iure ipsa velit odit quibusdam illum. Quaerat
cumque laudantium libero reprehenderit perferendis quo nulla
voluptate? Repellat tenetur labore exercitationem dicta libero
voluptate suscipit, iusto ea assumenda. Ipsa enim, quidem atque
modi error eaque, debitis perferendis, hic iste libero dignissimos
ea! Quod inventore beatae aspernatur nulla rem perferendis aperiam
at debitis delectus odit quia animi ex mollitia vero molestias
itaque deleniti, quos exercitationem consequatur assumenda dolor?
Quod reiciendis in similique reprehenderit commodi quo blanditiis
nobis hic ea optio illum placeat officia alias quasi autem earum
quos obcaecati, voluptatum corporis quisquam. Quisquam iste, quas
explicabo omnis harum aut quam adipisci, voluptatem saepe
accusantium doloribus repellendus amet culpa magnam ex et dolores
accusamus commodi facere aliquam voluptatum alias? Officia
expedita ut vel? Beatae deserunt sequi id eos libero suscipit
totam cum, sed architecto atque quisquam et incidunt quod fuga
ullam repellat assumenda quos ab, voluptatum sint nesciunt? Ad
sapiente est laborum quam sint eius sequi. Eum, veniam
dignissimos.
</div>
))}
</div>
</div>
</div>
);
};
export { ProgressiveBlur, Skiper41 };
/**
* Skiper 41 Canvas_Landing_004 — React + framer motion
* Inspired by and adapted from https://devouringdetails.com/
* We respect the original creators. This is an inspired rebuild with our own taste and does not claim any ownership.
* These animations arent associated with the devouringdetails.com . Theyre independent recreations meant to study interaction design
*
* License & Usage:
* - Free to use and modify in both personal and commercial projects.
* - Attribution to Skiper UI is required when using the free version.
* - No attribution required with Skiper UI Pro.
*
* Feedback and contributions are welcome.
*
* Author: @gurvinder-singh02
* Website: https://gxuri.me
* Twitter: https://x.com/Gur__vi
*/

View File

@@ -0,0 +1,34 @@
import Link from "next/link";
import { Coins } from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { Button } from "@/components/ui/button";
import { SignOutButton } from "./sign-out-button";
export async function UserNav() {
const u = await getCurrentUser();
if (!u) {
return (
<Button
asChild
className="h-12 cursor-pointer rounded-full bg-slate-900 px-7 text-base font-medium text-white transition-colors duration-200 hover:bg-slate-700"
>
<Link href="/giris">Giriş yap</Link>
</Button>
);
}
return (
<div className="flex items-center gap-2">
<Link
href="/paket"
className="inline-flex h-12 items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
title="Kredilerin — yüklemek için tıkla"
>
<Coins className="size-4 text-amber-500" aria-hidden />
{u.creditBalance} kredi
</Link>
<SignOutButton />
</div>
);
}

21
src/lib/ai/client.ts Normal file
View File

@@ -0,0 +1,21 @@
import Anthropic from "@anthropic-ai/sdk";
// Model seçimi (bilinçli): rapor tek seferlik ve yüksek değerli -> Opus 4.8;
// sohbet kredi başına marj hassas -> Haiku 4.5 (kullanıcı kararı, 22 Tem 2026).
export const RAPOR_MODEL = "claude-opus-4-8";
export const SOHBET_MODEL = "claude-haiku-4-5";
const globalForAi = globalThis as unknown as { __anthropic?: Anthropic };
export function getAnthropic(): Anthropic {
if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) {
throw new Error("AI_KEY_MISSING");
}
if (!globalForAi.__anthropic) {
globalForAi.__anthropic = new Anthropic();
}
return globalForAi.__anthropic;
}
export const SORUMLULUK_REDDI =
"KolayTercih bir karar destek aracıdır; öneriler resmî YÖK Atlas verisine dayanır ancak yerleşme garantisi verilmez. Nihai tercih sorumluluğu adaya aittir.";

179
src/lib/ai/rapor.ts Normal file
View File

@@ -0,0 +1,179 @@
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { getAnthropic, RAPOR_MODEL } from "./client";
import {
havuzOlustur,
havuzuKompaktJson,
turLabel,
type RaporParams,
type AdayProgram,
} from "../rapor-havuzu";
import { secimOzeti } from "../sihirbaz";
const TercihSchema = z.object({
programId: z.string(),
sira: z.number().int(),
dilim: z.enum(["hayal", "dengeli", "garanti"]),
gerekce: z.string(),
riskNotu: z.string(),
trendOzeti: z.string(),
});
const RaporSchema = z.object({
tercihler: z.array(TercihSchema),
genelDegerlendirme: z.string(),
});
export type RaporSonuc = z.infer<typeof RaporSchema> & {
// Render için havuzdan zenginleştirilmiş program bilgisi
programlar: Record<
string,
{ isim: string; universite: string; il: string | null; unitur: string | null }
>;
};
const SISTEM = `Sen KolayTercih'in kıdemli YKS tercih danışmanısın. Görevin: sana verilen
GERÇEK YÖK Atlas aday havuzundan, adayın sıralamasına göre 24 tercihlik dengeli
bir liste kurmak.
KURALLAR (kesin):
- SADECE havuzdaki programId'leri kullan. Havuzda olmayan program ASLA önerme.
- Tam 24 tercih üret; sira alanı 1'den 24'e sıralı olsun.
- Liste yapısı: en üstte 4-6 hayal, ortada 12-14 dengeli, sonda 5-7 garanti.
Tercih sırası MUTLAKA iyi taban sıralamasından kötüye doğru gitmeli
(küçük taban sıralaması = daha zor bölüm üstte).
- gerekce: bu programın bu aday için neden mantıklı olduğu (1-2 cümle, Türkçe).
- riskNotu: yerleşme ihtimali değerlendirmesi. ASLA "garanti", "kesin",
"yüzde yüz" deme; "yüksek/orta/düşük ihtimal" dili kullan.
- trendOzeti: son 4 yılın taban sıralaması hareketi (ör. "2022'den beri taban
sıralaması 45B'den 38B'ye indi, bölüm zorlaşıyor").
- Kontenjan artışı/azalışı ve yerleşen sayısını değerlendirmene kat.
- Adayın notlarındaki şehir/bölüm tercihlerine saygı göster ama dengeyi bozma.
- genelDegerlendirme: listenin genel stratejisi + adaya 2-3 pratik uyarı (Türkçe).`;
export class RaporUretimHatasi extends Error {}
export async function raporUret(
params: RaporParams,
revizyonNotu?: string,
oncekiListeOzeti?: string,
): Promise<RaporSonuc> {
const { havuz, gevsetildi } = havuzOlustur(params);
if (havuz.length < 24) {
throw new RaporUretimHatasi(
"Bu sıralama ve filtrelerle yeterli program bulunamadı. Daha geniş kategori veya il seçmeyi dene.",
);
}
const secimVar =
(params.kategoriler?.length ?? 0) > 0 ||
(params.iller?.length ?? 0) > 0 ||
(params.oncelikler?.length ?? 0) > 0 ||
(params.universiteTipi && params.universiteTipi !== "farketmez");
const gevsetmeNotu: string[] = [];
if (gevsetildi.includes("il")) {
gevsetmeNotu.push(
"Adayın seçtiği illerde yeterli program yoktu; havuza başka illerden de program eklendi. Seçili illere öncelik ver ama listeyi 24'e tamamlamak için diğerlerini de kullan.",
);
}
if (gevsetildi.includes("kategori")) {
gevsetmeNotu.push(
"Adayın seçtiği ilgi alanlarında yeterli program yoktu; havuza yakın alanlardan program eklendi. Seçili alanlara öncelik ver.",
);
}
const client = getAnthropic();
const havuzJson = havuzuKompaktJson(havuz);
const kullaniciMesaji = [
`Aday bilgisi: başarı sıralaması ${params.sira.toLocaleString("tr-TR")}, puan türü ${turLabel(params.tur)}.`,
secimVar ? `ADAYIN SİHİRBAZ SEÇİMLERİ:\n${secimOzeti({
kategoriler: params.kategoriler ?? [],
iller: params.iller ?? [],
universiteTipi: params.universiteTipi ?? "farketmez",
oncelikler: params.oncelikler ?? [],
})}\nSeçili kategori ve illere öncelik ver; havuzun elverdiği ölçüde bu seçimlere sadık kal.`
: null,
params.notlar ? `Adayın ek notları: ${params.notlar}` : null,
gevsetmeNotu.length > 0 ? gevsetmeNotu.join("\n") : null,
revizyonNotu
? `REVİZYON İSTEĞİ: Aday mevcut listede şu değişikliği istiyor: ${revizyonNotu}\nÖnceki listenin özeti: ${oncekiListeOzeti ?? "yok"}`
: null,
`ADAY HAVUZU (yalnızca buradan seç): ${havuzJson}`,
]
.filter(Boolean)
.join("\n\n");
const gecerliIdler = new Set(havuz.map((p) => p.id));
let sonuc: z.infer<typeof RaporSchema> | null = null;
let sonHata = "";
for (let deneme = 0; deneme < 2; deneme++) {
const response = await client.messages.parse({
model: RAPOR_MODEL,
max_tokens: 16000,
thinking: { type: "adaptive" },
system: SISTEM,
messages: [
{
role: "user",
content:
deneme === 0
? kullaniciMesaji
: `${kullaniciMesaji}\n\nÖNCEKİ DENEMENDE HATA VARDI: ${sonHata} Kurallara tam uy.`,
},
],
output_config: { format: zodOutputFormat(RaporSchema) },
});
const parsed = response.parsed_output;
if (!parsed) {
sonHata = ıktı şemaya uymadı.";
continue;
}
// Halüsinasyon koruması: her programId havuzda olmalı + tam 24 öğe
const bilinmeyen = parsed.tercihler.filter(
(t) => !gecerliIdler.has(t.programId),
);
if (parsed.tercihler.length !== 24 || bilinmeyen.length > 0) {
sonHata =
parsed.tercihler.length !== 24
? `Liste ${parsed.tercihler.length} öğe içeriyor, tam 24 olmalı.`
: `Havuzda olmayan programId kullanıldı: ${bilinmeyen
.map((t) => t.programId)
.slice(0, 3)
.join(", ")}.`;
continue;
}
sonuc = parsed;
break;
}
if (!sonuc) {
throw new RaporUretimHatasi(
"Rapor üretilemedi (doğrulama iki denemede de başarısız). Tekrar dener misin? Revizyon hakkın kullanılmadı.",
);
}
const programlar: RaporSonuc["programlar"] = {};
for (const p of havuz) {
programlar[p.id] = {
isim: p.isim,
universite: p.universite,
il: p.il,
unitur: p.unitur,
};
}
return { ...sonuc, programlar };
}
export function listeOzetiCikar(rapor: RaporSonuc): string {
return rapor.tercihler
.map(
(t) =>
`${t.sira}. [${t.dilim}] ${rapor.programlar[t.programId]?.isim ?? "?"}${rapor.programlar[t.programId]?.universite ?? "?"}`,
)
.join("\n");
}
export type { AdayProgram };

22
src/lib/appdb/index.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createClient, type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
// Lokal: file:./data/app.db — deploy: libsql://... + auth token.
// db.ts'teki better-sqlite3 bağlantısı gibi hot-reload'a karşı globalThis'te saklanır.
const globalForAppDb = globalThis as unknown as { __appDbClient?: Client };
function getClient(): Client {
if (!globalForAppDb.__appDbClient) {
const url = process.env.APP_DB_URL ?? "file:./data/app.db";
globalForAppDb.__appDbClient = createClient({
url,
authToken: process.env.APP_DB_AUTH_TOKEN,
});
}
return globalForAppDb.__appDbClient;
}
export const appDb = drizzle(getClient(), { schema });
export { schema };

147
src/lib/appdb/schema.ts Normal file
View File

@@ -0,0 +1,147 @@
import {
sqliteTable,
text,
integer,
uniqueIndex,
index,
} from "drizzle-orm/sqlite-core";
// ---- better-auth çekirdek tabloları (+ kredi alanları) ----
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" })
.notNull()
.default(false),
image: text("image"),
creditBalance: integer("credit_balance").notNull().default(0),
hasPaket: integer("has_paket", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
token: text("token").notNull().unique(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }),
updatedAt: integer("updated_at", { mode: "timestamp" }),
});
// ---- Ürün tabloları ----
export const orders = sqliteTable(
"orders",
{
// cuid — iyzico conversationId olarak da kullanılır
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id),
product: text("product", { enum: ["paket", "topup"] }).notNull(),
amountKurus: integer("amount_kurus").notNull(),
credits: integer("credits").notNull(),
status: text("status", { enum: ["pending", "paid", "failed"] })
.notNull()
.default("pending"),
iyzicoToken: text("iyzico_token"),
iyzicoPaymentId: text("iyzico_payment_id"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
paidAt: integer("paid_at", { mode: "timestamp" }),
},
(t) => [index("orders_user").on(t.userId)],
);
export const creditLedger = sqliteTable(
"credit_ledger",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id),
delta: integer("delta").notNull(),
reason: text("reason", {
enum: [
"trial_grant",
"purchase",
"topup",
"chat_message",
"refund",
"report_generate",
"report_revision",
],
}).notNull(),
refId: text("ref_id"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [
uniqueIndex("ledger_reason_ref").on(t.reason, t.refId),
index("ledger_user").on(t.userId),
],
);
export const chatMessages = sqliteTable(
"chat_messages",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id),
role: text("role", { enum: ["user", "assistant"] }).notNull(),
content: text("content").notNull(),
clientMessageId: text("client_message_id").unique(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(t) => [index("chat_user_created").on(t.userId, t.createdAt)],
);
export const reports = sqliteTable("reports", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.unique()
.references(() => user.id),
params: text("params", { mode: "json" }).notNull(),
result: text("result", { mode: "json" }),
revisionCount: integer("revision_count").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

8
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,8 @@
"use client";
import { createAuthClient } from "better-auth/react";
import { magicLinkClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [magicLinkClient()],
});

71
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,71 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { magicLink } from "better-auth/plugins";
import { appDb, schema } from "./appdb";
import { grantCredits, DENEME_KREDISI } from "./credits";
async function sendMagicLinkEmail(email: string, url: string) {
if (!process.env.RESEND_API_KEY) {
// Dev fallback: Resend anahtarı yoksa linki terminale yaz
console.log(`\n[giris] Magic link for ${email}:\n${url}\n`);
return;
}
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: process.env.EMAIL_FROM ?? "KolayTercih <onboarding@resend.dev>",
to: email,
subject: "KolayTercih giriş bağlantın",
html: `<p>Merhaba,</p><p>KolayTercih'e giriş yapmak için <a href="${url}">buraya tıkla</a>. Bağlantı 5 dakika geçerlidir.</p><p>Bu isteği sen yapmadıysan bu e-postayı yok sayabilirsin.</p>`,
});
}
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
secret: process.env.BETTER_AUTH_SECRET,
database: drizzleAdapter(appDb, {
provider: "sqlite",
schema: {
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
},
}),
user: {
additionalFields: {
creditBalance: { type: "number", defaultValue: 0, input: false },
hasPaket: { type: "boolean", defaultValue: false, input: false },
},
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID ?? "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
},
},
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendMagicLinkEmail(email, url);
},
}),
],
databaseHooks: {
user: {
create: {
after: async (newUser) => {
// refId = userId → UNIQUE(reason, refId) ile tek seferlik
await grantCredits({
userId: newUser.id,
delta: DENEME_KREDISI,
reason: "trial_grant",
refId: newUser.id,
});
},
},
},
},
});
export type Session = typeof auth.$Infer.Session;

149
src/lib/credits.ts Normal file
View File

@@ -0,0 +1,149 @@
import { and, eq, gte, sql } from "drizzle-orm";
import { appDb, schema } from "./appdb";
const { user, creditLedger, chatMessages } = schema;
export const URUNLER = {
paket: { amountKurus: 29900, credits: 60, label: "Tercih Dönemi Paketi" },
topup: { amountKurus: 12900, credits: 30, label: "+30 Kredi" },
} as const;
export const DENEME_KREDISI = 5;
// Rapor üretimi/revizyonu başına düşen kredi
export const RAPOR_KREDI = 3;
export type SpendResult =
| { ok: true }
| { ok: false; error: "INSUFFICIENT" | "DUPLICATE" };
function newId() {
return crypto.randomUUID();
}
/**
* Sabit tutarda kredi düşer + ledger kaydı — tek transaction, iade edilebilir.
* Rapor üretimi ve revizyonu için (3 kredi). refId idempotency anahtarıdır:
* aynı requestId ile ikinci çağrı DUPLICATE döner (çifte harcama olmaz).
* Bakiye yetersizse hiçbir şey yazılmaz.
*/
export async function spendCredits(opts: {
userId: string;
amount: number;
reason: "report_generate" | "report_revision";
refId: string;
}): Promise<SpendResult> {
try {
await appDb.transaction(async (tx) => {
const res = await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} - ${opts.amount}`,
updatedAt: new Date(),
})
.where(
and(eq(user.id, opts.userId), gte(user.creditBalance, opts.amount)),
);
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
await tx.insert(creditLedger).values({
id: newId(),
userId: opts.userId,
delta: -opts.amount,
reason: opts.reason,
refId: opts.refId,
createdAt: new Date(),
});
});
return { ok: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
if (msg.includes("UNIQUE")) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
/**
* 1 kredi düşer + ledger kaydı + kullanıcı mesajını persist eder — tek transaction.
* Bakiye yetersizse hiçbir şey yazılmaz. clientMessageId unique → retry'da çifte
* harcama yerine DUPLICATE döner.
*/
export async function spendCreditForMessage(opts: {
userId: string;
content: string;
clientMessageId: string;
}): Promise<SpendResult & { messageId?: string }> {
const messageId = newId();
try {
await appDb.transaction(async (tx) => {
const res = await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} - 1`,
updatedAt: new Date(),
})
.where(and(eq(user.id, opts.userId), gte(user.creditBalance, 1)));
if (res.rowsAffected === 0) throw new Error("INSUFFICIENT");
await tx.insert(chatMessages).values({
id: messageId,
userId: opts.userId,
role: "user",
content: opts.content,
clientMessageId: opts.clientMessageId,
createdAt: new Date(),
});
await tx.insert(creditLedger).values({
id: newId(),
userId: opts.userId,
delta: -1,
reason: "chat_message",
refId: messageId,
createdAt: new Date(),
});
});
return { ok: true, messageId };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INSUFFICIENT") return { ok: false, error: "INSUFFICIENT" };
if (msg.includes("UNIQUE")) return { ok: false, error: "DUPLICATE" };
throw err;
}
}
/**
* Kredi tanımlama (deneme, satın alma, top-up, iade). UNIQUE(reason, refId)
* sayesinde aynı referansla ikinci çağrı sessizce no-op olur → idempotent.
*/
export async function grantCredits(opts: {
userId: string;
delta: number;
reason: "trial_grant" | "purchase" | "topup" | "refund";
refId: string;
setHasPaket?: boolean;
}): Promise<{ granted: boolean }> {
try {
await appDb.transaction(async (tx) => {
await tx.insert(creditLedger).values({
id: newId(),
userId: opts.userId,
delta: opts.delta,
reason: opts.reason,
refId: opts.refId,
createdAt: new Date(),
});
await tx
.update(user)
.set({
creditBalance: sql`${user.creditBalance} + ${opts.delta}`,
...(opts.setHasPaket ? { hasPaket: true } : {}),
updatedAt: new Date(),
})
.where(eq(user.id, opts.userId));
});
return { granted: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("UNIQUE")) return { granted: false };
throw err;
}
}

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import Database from "better-sqlite3";
import { KATEGORILER, kategoriBul } from "./kategoriler";
export type Program = {
id: string;
@@ -62,20 +63,47 @@ const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)";
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
* demektir (hayal), büyükse daha güvenli demektir (garanti).
*/
export type UniturGrubu = "devlet" | "vakif";
// Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO,
// YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer.
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
if (grup === "vakif")
return { sql: "AND unitur IS NOT NULL AND unitur <> ?", args: ["DEVLET"] };
return { sql: "", args: [] };
}
export function searchByRank(
sira: number,
turKey: PuanTuruKey,
opts: { il?: string; limitPerBucket?: number } = {}
opts: {
il?: string;
iller?: string[];
uniturGrubu?: UniturGrubu;
limitPerBucket?: number;
} = {}
): RankResults {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const limitPerBucket = opts.limitPerBucket ?? 12;
const ilFiltre = opts.il ? "AND il = ?" : "";
const ilArgs = opts.il ? [opts.il] : [];
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
const illerListe = [
...(opts.il ? [opts.il] : []),
...(opts.iller ?? []),
].filter((v, i, a) => v && a.indexOf(v) === i);
const ilFiltre =
illerListe.length > 0
? `AND il IN (${illerListe.map(() => "?").join(",")})`
: "";
const unitur = uniturFiltreSql(opts.uniturGrubu);
const ilArgs = [...illerListe, ...unitur.args];
const base = `FROM programs
WHERE tur = ? AND onlisans = ? ${ilFiltre} AND ${EFEKTIF_SIRA} IS NOT NULL
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql}
AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
// hayal: kullanıcıdan daha iyi taban sıralaması (sınıra en yakın olanlar önce)
@@ -122,3 +150,78 @@ export function searchByRank(
garanti: garanti as Program[],
};
}
export type SihirbazFacetleri = {
kategoriler: { ad: string; adet: number }[];
iller: { il: string; adet: number }[];
uniturler: { grup: UniturGrubu; adet: number }[];
};
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal alt sınırından
// (sira*0.5) garanti üst sınırına (sira*3) kadar. Böylece 500B sıradaki aday Tıp
// bubble'ı görmez; 5B sıradaki görür.
export function rankWindowFacets(
sira: number,
turKey: PuanTuruKey
): SihirbazFacetleri {
const db = getDb();
const tur = PUAN_TURLERI[turKey];
const onlisans = turKey === "tyt" ? 1 : 0;
const altSinir = Math.round(sira * 0.5);
const ustSinir = Math.round(sira * 3);
const base = `FROM programs
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`;
// isim bazında say → JS'te kategoriye çevir (bir isim çok kategoriye girebilir)
const isimSayilari = db
.prepare(`SELECT isim, COUNT(*) AS adet ${base} GROUP BY isim`)
.all(tur, onlisans, altSinir, ustSinir) as {
isim: string;
adet: number;
}[];
const kategoriSay = new Map<string, number>();
for (const { isim, adet } of isimSayilari) {
for (const k of kategoriBul(isim)) {
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + adet);
}
}
const kategoriler = KATEGORILER.map((ad) => ({
ad,
adet: kategoriSay.get(ad) ?? 0,
}))
.filter((k) => k.adet >= 3) // gürültüyü ele
.sort((a, b) => b.adet - a.adet);
const iller = (
db
.prepare(
`SELECT il, COUNT(*) AS adet ${base} AND il IS NOT NULL AND il <> ''
GROUP BY il ORDER BY adet DESC LIMIT 15`
)
.all(tur, onlisans, altSinir, ustSinir) as {
il: string;
adet: number;
}[]
).filter((r) => r.adet > 0);
const uniturSatirlari = db
.prepare(`SELECT unitur, COUNT(*) AS adet ${base} GROUP BY unitur`)
.all(tur, onlisans, altSinir, ustSinir) as {
unitur: string | null;
adet: number;
}[];
let devlet = 0;
let vakif = 0;
for (const { unitur, adet } of uniturSatirlari) {
if (unitur === "DEVLET") devlet += adet;
else if (unitur) vakif += adet;
}
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });
if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif });
return { kategoriler, iller, uniturler };
}

112
src/lib/iyzico.ts Normal file
View File

@@ -0,0 +1,112 @@
import Iyzipay from "iyzipay";
// iyzipay CJS + callback tabanlı; burada promisify'lı ince bir katman var.
// Anahtarlar boşsa (henüz sandbox hesabı yoksa) çağrı anlaşılır bir hatayla düşer.
const globalForIyzi = globalThis as unknown as { __iyzipay?: Iyzipay };
function getClient(): Iyzipay {
if (!process.env.IYZICO_API_KEY || !process.env.IYZICO_SECRET_KEY) {
throw new Error("IYZICO_KEYS_MISSING");
}
if (!globalForIyzi.__iyzipay) {
globalForIyzi.__iyzipay = new Iyzipay({
apiKey: process.env.IYZICO_API_KEY,
secretKey: process.env.IYZICO_SECRET_KEY,
uri: process.env.IYZICO_BASE_URL ?? "https://sandbox-api.iyzipay.com",
});
}
return globalForIyzi.__iyzipay;
}
export interface CheckoutFormInitSonuc {
status: string;
token?: string;
paymentPageUrl?: string;
errorMessage?: string;
}
export interface CheckoutFormSonuc {
status: string;
paymentStatus?: string; // "SUCCESS" beklenir
conversationId?: string;
paymentId?: string;
paidPrice?: string | number;
errorMessage?: string;
}
export function initializeCheckoutForm(opts: {
orderId: string;
fiyatKurus: number;
urunAdi: string;
email: string;
userId: string;
callbackUrl: string;
buyerIp: string;
}): Promise<CheckoutFormInitSonuc> {
const price = (opts.fiyatKurus / 100).toFixed(2);
// iyzico buyer bloğu zorunlu alanlar ister; dijital üründe adres sembolik
const adres = {
contactName: "KolayTercih Kullanıcısı",
city: "Istanbul",
country: "Turkey",
address: "Dijital teslimat",
};
const request = {
locale: Iyzipay.LOCALE.TR,
conversationId: opts.orderId,
price,
paidPrice: price,
currency: Iyzipay.CURRENCY.TRY,
basketId: opts.orderId,
paymentGroup: Iyzipay.PAYMENT_GROUP.PRODUCT,
callbackUrl: opts.callbackUrl,
enabledInstallments: [1],
buyer: {
id: opts.userId,
name: "KolayTercih",
surname: "Kullanıcısı",
gsmNumber: "+905000000000",
email: opts.email,
identityNumber: "11111111111",
registrationAddress: adres.address,
ip: opts.buyerIp,
city: adres.city,
country: adres.country,
},
shippingAddress: adres,
billingAddress: adres,
basketItems: [
{
id: opts.orderId,
name: opts.urunAdi,
category1: "Dijital Hizmet",
itemType: Iyzipay.BASKET_ITEM_TYPE.VIRTUAL,
price,
},
],
};
return new Promise((resolve, reject) => {
getClient().checkoutFormInitialize.create(
request as never,
(err: unknown, result: CheckoutFormInitSonuc) => {
if (err) reject(err);
else resolve(result);
},
);
});
}
export function retrieveCheckoutForm(
token: string,
): Promise<CheckoutFormSonuc> {
return new Promise((resolve, reject) => {
getClient().checkoutForm.retrieve(
{ locale: Iyzipay.LOCALE.TR, token } as never,
(err: unknown, result: CheckoutFormSonuc) => {
if (err) reject(err);
else resolve(result);
},
);
});
}

102
src/lib/kategoriler.ts Normal file
View File

@@ -0,0 +1,102 @@
// Program adını (isim) insan-dostu ilgi kategorilerine eşler.
// Sihirbazdaki bubble'lar ve aday havuzu filtresi buradan beslenir.
// Kural: bir program birden fazla kategoriye girebilir; hiçbirine girmezse
// "Diğer" sayılır (bubble olarak gösterilmez ama havuzdan asla dışlanmaz).
export const KATEGORILER = [
"Mühendislik & Teknoloji",
"Sağlık & Tıp",
"Hukuk & Siyasal",
"İktisat & İşletme",
"Eğitim & Öğretmenlik",
"Mimarlık & Tasarım",
"Fen & Temel Bilimler",
"Sosyal & Beşeri Bilimler",
"Sanat, Medya & İletişim",
"Psikoloji & Danışmanlık",
"Turizm & Gastronomi",
"Tarım & Doğa",
] as const;
export type Kategori = (typeof KATEGORILER)[number];
// Sıra önemli değil; her kural bağımsız test edilir. Regexler Türkçe küçük harfe
// çevrilmiş isim üzerinde çalışır (bkz. kategoriBul).
const KATEGORI_KURALLARI: { kategori: Kategori; kural: RegExp }[] = [
{
kategori: "Mühendislik & Teknoloji",
kural:
/mühendis|yazılım|bilgisayar|bilişim|elektrik|elektronik|mekatronik|makine|makina|otomotiv|inşaat|endüstri|metalurji|malzeme|maden|jeoloji|jeofizik|petrol|enerji|robot|yapay zeka|yapay zekâ|siber|ağ teknolojileri|kontrol|otomasyon|uçak|havacılık|uzay|gemi|deniz ulaştırma|teknoloji|teknolojisi|programcı|elektrik-elektronik|nükleer|tekstil müh/,
},
{
kategori: "Sağlık & Tıp",
kural:
/\btıp\b|diş|hemşire|eczacı|fizyoterapi|beslenme|diyetetik|anestezi|görüntüleme|laboratuvar|dokümantasyon|acil|paramedik|optisyen|ameliyathane|sağlık|odyoloji|dil ve konuşma|ebelik|veteriner|biyomedikal|ortez|protez|diyaliz|patoloji|radyoterapi|perfüzyon|çocuk gelişimi|yaşlı bakım|podoloji|ağız ve diş|tıbbi/,
},
{
kategori: "Hukuk & Siyasal",
kural:
/hukuk|adalet|siyaset|kamu yönetim|uluslararası ilişki|siyasal|güvenlik|ceza infaz|milletlerarası|diplomasi/,
},
{
kategori: "İktisat & İşletme",
kural:
/işletme|iktisat|ekonomi|maliye|muhasebe|finans|bankacılık|sigortacılık|dış ticaret|lojistik|pazarlama|yönetim bilişim|insan kaynak|çalışma ekonomisi|uluslararası ticaret|bankacılık ve sigortacılık|halkla ilişkiler|reklamcılık|emlak|menkul|gümrük|büro yönetim|yönetici asistan/,
},
{
kategori: "Eğitim & Öğretmenlik",
kural:
/öğretmen|öğretmenliği|eğitim|okul öncesi|rehberlik ve psikolojik|çocuk gelişimi|pedagoji|özel eğitim/,
},
{
kategori: "Mimarlık & Tasarım",
kural:
/mimar|iç mimar|peyzaj|şehir ve bölge|tasarım|endüstriyel tasarım|moda|iç mekan/,
},
{
kategori: "Fen & Temel Bilimler",
kural:
/matematik|fizik|kimya|biyoloji|istatistik|moleküler|genetik|biyoteknoloji|astronomi|aktüerya|fen bilim|bilim/,
},
{
kategori: "Sosyal & Beşeri Bilimler",
kural:
/sosyoloji|tarih|coğrafya|felsefe|edebiyat|dil ve edebiyat|filoloji|arkeoloji|antropoloji|ilahiyat|din kültürü|dilbilim|sosyal hizmet|mütercim|çeviribilim|tercümanlık|türk dili|dünya dilleri|sanat tarihi|halkbilim|dinî/,
},
{
kategori: "Sanat, Medya & İletişim",
kural:
/gazetecilik|iletişim|radyo|televizyon|sinema|görsel|grafik|müzik|resim|heykel|sahne|tiyatro|oyunculuk|fotoğraf|animasyon|seramik|güzel sanatlar|medya|yeni medya|halkla ilişkiler ve tanıtım|reklam|film|sanat ve tasarım/,
},
{
kategori: "Psikoloji & Danışmanlık",
kural: /psikoloji|psikolojik danışma|rehberlik ve psikolojik|davranış/,
},
{
kategori: "Turizm & Gastronomi",
kural:
/turizm|gastronomi|mutfak|aşçılık|otel|seyahat|rekreasyon|konaklama|ikram|sivil havacılık kabin/,
},
{
kategori: "Tarım & Doğa",
kural:
/ziraat|tarım|tarla|bahçe|bitki|hayvan|su ürünleri|gıda|orman|çevre|balıılık|peyzaj|tohum|toprak|zootekni/,
},
];
/** Verilen program adının girdiği tüm kategorileri döner (0..n). */
export function kategoriBul(isim: string): Kategori[] {
const lower = isim.toLocaleLowerCase("tr-TR");
const sonuc: Kategori[] = [];
for (const { kategori, kural } of KATEGORI_KURALLARI) {
if (kural.test(lower)) sonuc.push(kategori);
}
return sonuc;
}
/** Bir programın seçili kategorilerden en az biriyle eşleşip eşleşmediği. */
export function kategoriEslesir(isim: string, secililer: string[]): boolean {
if (secililer.length === 0) return true;
const kats = kategoriBul(isim);
return kats.some((k) => secililer.includes(k));
}

63
src/lib/odeme.ts Normal file
View File

@@ -0,0 +1,63 @@
import { and, eq } from "drizzle-orm";
import { appDb, schema } from "./appdb";
import { grantCredits, URUNLER } from "./credits";
import { retrieveCheckoutForm } from "./iyzico";
const { orders } = schema;
/**
* Token'la iyzico'dan sonucu çeker ve başarılıysa krediyi İDEMPOTENT tanımlar.
* Hem callback route'u hem /odeme/sonuc self-healing fallback'i bunu kullanır.
* Dönen değer: siparişin son durumu.
*/
export async function odemeyiSonuclandir(
orderId: string,
): Promise<"paid" | "pending" | "failed" | "not_found"> {
const order = await appDb.query.orders.findFirst({
where: eq(orders.id, orderId),
});
if (!order) return "not_found";
if (order.status === "paid") return "paid";
if (!order.iyzicoToken) return order.status;
let sonuc;
try {
sonuc = await retrieveCheckoutForm(order.iyzicoToken);
} catch {
return order.status; // iyzico'ya ulaşılamadı; durumu değiştirme
}
if (sonuc.status !== "success" || sonuc.paymentStatus !== "SUCCESS") {
await appDb
.update(orders)
.set({ status: "failed" })
.where(and(eq(orders.id, orderId), eq(orders.status, "pending")));
return "failed";
}
if (sonuc.conversationId && sonuc.conversationId !== order.id) {
return order.status; // conversationId uyuşmazlığı: işleme
}
// pending -> paid koşullu geçiş: 0 satır = başka istek zaten işledi
const res = await appDb
.update(orders)
.set({
status: "paid",
iyzicoPaymentId: sonuc.paymentId ?? null,
paidAt: new Date(),
})
.where(and(eq(orders.id, orderId), eq(orders.status, "pending")));
if (res.rowsAffected === 0) return "paid";
// UNIQUE(reason, refId) ikinci katman güvence
await grantCredits({
userId: order.userId,
delta: order.credits,
reason: order.product === "paket" ? "purchase" : "topup",
refId: order.id,
setHasPaket: order.product === "paket",
});
return "paid";
}
export { URUNLER };

133
src/lib/rapor-havuzu.ts Normal file
View File

@@ -0,0 +1,133 @@
import {
searchByRank,
type Program,
type PuanTuruKey,
type UniturGrubu,
PUAN_TURLERI,
} from "./db";
import { kategoriEslesir } from "./kategoriler";
export type Dilim = "hayal" | "dengeli" | "garanti";
export interface AdayProgram extends Program {
dilim: Dilim;
}
export interface RaporParams {
sira: number;
tur: PuanTuruKey;
// Sihirbaz seçimleri (yeni akış)
kategoriler?: string[];
iller?: string[];
universiteTipi?: UniturGrubu | "farketmez";
oncelikler?: string[];
// Eski akış alanları (geriye uyum — eski kayıtlı raporlar)
il?: string;
notlar?: string;
}
export interface HavuzSonuc {
havuz: AdayProgram[];
// Havuz 24'e ulaşmak için hangi filtreler gevşetildi
gevsetildi: ("kategori" | "il")[];
}
const HEDEF = 24;
const LIMIT = 60;
function dilimEkle(r: {
hayal: Program[];
dengeli: Program[];
garanti: Program[];
}): AdayProgram[] {
return [
...r.hayal.map((p) => ({ ...p, dilim: "hayal" as const })),
...r.dengeli.map((p) => ({ ...p, dilim: "dengeli" as const })),
...r.garanti.map((p) => ({ ...p, dilim: "garanti" as const })),
];
}
/**
* Deterministik aday havuzu: Claude bu havuzdan SEÇER, asla uydurmaz.
* Sihirbaz seçimlerine (kategori/il/üniversite tipi) göre filtreler; havuz
* 24'ün altına düşerse kademeli gevşetir (önce il, sonra kategori) ki liste
* her zaman kurulabilsin.
*/
export function havuzOlustur(params: RaporParams): HavuzSonuc {
const uniturGrubu =
params.universiteTipi && params.universiteTipi !== "farketmez"
? params.universiteTipi
: undefined;
const kategoriler = params.kategoriler ?? [];
const iller = [
...(params.il ? [params.il] : []),
...(params.iller ?? []),
].filter((v, i, a) => v && a.indexOf(v) === i);
const kategoriFiltre = (havuz: AdayProgram[]) =>
kategoriler.length > 0
? havuz.filter((p) => kategoriEslesir(p.isim, kategoriler))
: havuz;
// 1. Tam filtre: iller + üniversite tipi (SQL), kategori (JS)
const tamHam = dilimEkle(
searchByRank(params.sira, params.tur, {
iller: iller.length > 0 ? iller : undefined,
uniturGrubu,
limitPerBucket: LIMIT,
}),
);
const tamFiltreli = kategoriFiltre(tamHam);
if (tamFiltreli.length >= HEDEF) {
return { havuz: tamFiltreli, gevsetildi: [] };
}
// 2. Kategori gevşet: aynı illerdeki kategori-dışı programlarla doldur
if (kategoriler.length > 0 && tamHam.length >= HEDEF) {
return { havuz: tamHam, gevsetildi: ["kategori"] };
}
// 3. İl gevşet: il filtresini kaldırıp kategoriye geri dön
const gevsetildi: ("kategori" | "il")[] = [];
if (iller.length > 0) gevsetildi.push("il");
const ilsizHam = dilimEkle(
searchByRank(params.sira, params.tur, {
uniturGrubu,
limitPerBucket: LIMIT,
}),
);
const ilsizFiltreli = kategoriFiltre(ilsizHam);
if (ilsizFiltreli.length >= HEDEF) {
return { havuz: ilsizFiltreli, gevsetildi };
}
// 4. Hem il hem kategori gevşet: elde ne varsa
if (kategoriler.length > 0) gevsetildi.push("kategori");
return { havuz: ilsizHam, gevsetildi };
}
/** Prompt'a enjekte edilecek kompakt satır (token tasarrufu). */
export function havuzuKompaktJson(havuz: AdayProgram[]): string {
return JSON.stringify(
havuz.map((p) => ({
id: p.id,
isim: p.isim,
uni: p.universite,
unitur: p.unitur,
il: p.il,
dilim: p.dilim,
sira: {
y2025: p.sira2025,
y2024: p.sira2024,
y2023: p.sira2023,
y2022: p.sira2022,
},
kontenjan2025: p.kontenjan2025,
yerlesen2025: p.yerlesen2025,
})),
);
}
export function turLabel(tur: PuanTuruKey): string {
return PUAN_TURLERI[tur];
}

35
src/lib/session.ts Normal file
View File

@@ -0,0 +1,35 @@
import { cache } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { auth } from "./auth";
import { appDb, schema } from "./appdb";
/** İstek başına tek session sorgusu (React cache). */
export const getSession = cache(async () => {
return auth.api.getSession({ headers: await headers() });
});
/** Oturum yoksa /giris'e yönlendirir; callback ile geri dönüş desteklenir. */
export async function verifySession(callbackPath?: string) {
const session = await getSession();
if (!session) {
const target = callbackPath
? `/giris?callback=${encodeURIComponent(callbackPath)}`
: "/giris";
redirect(target);
}
return session;
}
/** Kredi/paket alanları dahil güncel kullanıcı satırı (session cache'ine güvenme). */
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
const rows = await appDb
.select()
.from(schema.user)
.where(eq(schema.user.id, session.user.id))
.limit(1);
return rows[0] ?? null;
});

108
src/lib/sihirbaz.ts Normal file
View File

@@ -0,0 +1,108 @@
// Sonuç sayfasındaki sihirbaz modalının seçim şeması, doğrulaması ve prompt özeti.
// Kategoriler/iller sıralamaya göre dinamik üretilir (bkz. rankWindowFacets);
// öncelikler statik kalır (veriye bağlı değil, tercih niyeti).
import { KATEGORILER } from "./kategoriler";
import type { UniturGrubu } from "./db";
export const ONCELIKLER = [
"İş garantisi",
"Yüksek maaş potansiyeli",
"Sevdiğim işi yapmak",
"Akademik kariyer",
"Yurtdışı imkânı",
"Prestijli üniversite",
"Kolay yerleşme",
] as const;
export const UNIVERSITE_TIPI_ETIKET: Record<UniturGrubu | "farketmez", string> = {
devlet: "Devlet",
vakif: "Vakıf (burslu olursa)",
farketmez: "Fark etmez",
};
export type UniversiteTipi = UniturGrubu | "farketmez";
// Girişsiz kullanıcının modalda yaptığı seçimler, giriş sonrası /sonuc'a
// dönene kadar burada bekler.
export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz";
export interface SihirbazSecimleri {
kategoriler: string[];
iller: string[];
universiteTipi: UniversiteTipi;
oncelikler: string[];
}
const UNI_TIPLERI: UniversiteTipi[] = ["devlet", "vakif", "farketmez"];
/**
* Client'tan gelen seçimleri güvene alır. Kategoriler KATEGORILER allowlist'ine
* göre süzülür; iller serbest string (yalnızca sanity — DB IN filtresi zaten
* eşleşmeyeni yok sayar), en fazla 5 il; öncelikler ONCELIKLER'e göre süzülür.
* En az bir kategori zorunlu.
*/
export function sihirbazDogrula(girdi: unknown): SihirbazSecimleri | null {
if (typeof girdi !== "object" || girdi === null) return null;
const p = girdi as Record<string, unknown>;
const kategoriler = Array.isArray(p.kategoriler)
? [
...new Set(
p.kategoriler.filter(
(x): x is string =>
typeof x === "string" &&
(KATEGORILER as readonly string[]).includes(x),
),
),
]
: [];
if (kategoriler.length === 0) return null;
const iller = Array.isArray(p.iller)
? [
...new Set(
p.iller.filter(
(x): x is string => typeof x === "string" && x.trim().length > 0,
),
),
].slice(0, 5)
: [];
const universiteTipi =
typeof p.universiteTipi === "string" &&
UNI_TIPLERI.includes(p.universiteTipi as UniversiteTipi)
? (p.universiteTipi as UniversiteTipi)
: "farketmez";
const oncelikler = Array.isArray(p.oncelikler)
? [
...new Set(
p.oncelikler.filter(
(x): x is string =>
typeof x === "string" &&
(ONCELIKLER as readonly string[]).includes(x),
),
),
]
: [];
return { kategoriler, iller, universiteTipi, oncelikler };
}
/** Prompt'a enjekte edilecek Türkçe özet. */
export function secimOzeti(secimler: SihirbazSecimleri): string {
const satirlar = [
`- İlgi alanları: ${secimler.kategoriler.join(", ")}`,
];
if (secimler.iller.length > 0) {
satirlar.push(`- Tercih ettiği iller: ${secimler.iller.join(", ")}`);
}
satirlar.push(
`- Üniversite tipi tercihi: ${UNIVERSITE_TIPI_ETIKET[secimler.universiteTipi]}`,
);
if (secimler.oncelikler.length > 0) {
satirlar.push(`- Öncelikleri: ${secimler.oncelikler.join(", ")}`);
}
return satirlar.join("\n");
}