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

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

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

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

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

View File

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

View File

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

View File

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

View File

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