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:
4
src/app/api/auth/[...all]/route.ts
Normal file
4
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
30
src/app/api/odeme/callback/route.ts
Normal file
30
src/app/api/odeme/callback/route.ts
Normal 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
237
src/app/api/soru/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
130
src/app/giris/giris-form.tsx
Normal file
130
src/app/giris/giris-form.tsx
Normal 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
40
src/app/giris/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
96
src/app/odeme/sonuc/page.tsx
Normal file
96
src/app/odeme/sonuc/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
73
src/app/paket/actions.ts
Normal 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
157
src/app/paket/page.tsx
Normal 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.000–10.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>
|
||||
);
|
||||
}
|
||||
47
src/app/paket/satin-al-form.tsx
Normal file
47
src/app/paket/satin-al-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/app/rapor/yazdir/page.tsx
Normal file
110
src/app/rapor/yazdir/page.tsx
Normal 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 (2021–2025 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>
|
||||
);
|
||||
}
|
||||
22
src/app/rapor/yazdir/yazdir-butonu.tsx
Normal file
22
src/app/rapor/yazdir/yazdir-butonu.tsx
Normal 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">
|
||||
"PDF olarak kaydet" 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
252
src/app/sonuc/actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
201
src/app/sonuc/sihirbaz-cagri-karti.tsx
Normal file
201
src/app/sonuc/sihirbaz-cagri-karti.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
636
src/app/sonuc/sihirbaz-modal.tsx
Normal file
636
src/app/sonuc/sihirbaz-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/app/sonuc/soru-kutusu.tsx
Normal file
222
src/app/sonuc/soru-kutusu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/components/header-gate.tsx
Normal file
15
src/components/header-gate.tsx
Normal 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}</>;
|
||||
}
|
||||
83
src/components/rapor-listesi.tsx
Normal file
83
src/components/rapor-listesi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/sign-out-button.tsx
Normal file
24
src/components/sign-out-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/site-header.tsx
Normal file
39
src/components/site-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/ui/skiper-ui/skiper41.tsx
Normal file
104
src/components/ui/skiper-ui/skiper41.tsx
Normal 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 aren’t associated with the devouringdetails.com . They’re 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
|
||||
*/
|
||||
34
src/components/user-nav.tsx
Normal file
34
src/components/user-nav.tsx
Normal 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
21
src/lib/ai/client.ts
Normal 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
179
src/lib/ai/rapor.ts
Normal 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
22
src/lib/appdb/index.ts
Normal 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
147
src/lib/appdb/schema.ts
Normal 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
8
src/lib/auth-client.ts
Normal 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
71
src/lib/auth.ts
Normal 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
149
src/lib/credits.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
111
src/lib/db.ts
111
src/lib/db.ts
@@ -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
112
src/lib/iyzico.ts
Normal 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
102
src/lib/kategoriler.ts
Normal 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ıkçı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
63
src/lib/odeme.ts
Normal 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
133
src/lib/rapor-havuzu.ts
Normal 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
35
src/lib/session.ts
Normal 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
108
src/lib/sihirbaz.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user