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

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

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

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

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

View File

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