refactor: odeme feature'ı + son süpürme — geçiş tamamlandı (Faz 10)
Some checks failed
Deploy / deploy (push) Failing after 3m46s
Some checks failed
Deploy / deploy (push) Failing after 3m46s
- paket/actions.ts → odeme-actions.ts; satin-al-form + rapor-yok-cta features/odeme/components/ altına; odeme-queries.ts (siparisGetir) - Yeni async OdemeSonucKarti: verifySession + sahiplik kontrolü + self-healing odemeyiSonuclandir çağrısı akıştaki yerinde (artık Suspense altındaki feature bileşeninde, aynı render semantiği); başarı/hata kartları içeride - Yeni async PaketSatinAl: paket + top-up kartları, getCurrentUser okuması içeride; statik hero/dipnot sayfada kaldı - İki sayfa senkron searchParams.then(); paket hata uyarısı sayfada çözülmüş JSX (hata yokken hiçbir şey çizilmediği için fallback bilerek null); paket/loading.tsx + odeme/sonuc/loading.tsx silindi - Süpürme doğrulandı: app/ altında route-dışı bileşen dosyası kalmadı; eski yol grep'leri sıfır; 7 *-queries server-only, 3 *-actions 'use server'; async sayfa sıfır; /api/odeme/callback el değmedi - Davranış değişikliği yok Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
// /odeme/sonuc iyzico doğrulaması bekler — ortalanmış sonuç kartı iskeleti.
|
|
||||||
export default function Loading() {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
aria-live="polite"
|
|
||||||
className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16"
|
|
||||||
>
|
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
|
||||||
<Skeleton className="mx-auto size-12 rounded-full" />
|
|
||||||
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
|
|
||||||
<Skeleton className="mx-auto mt-3 h-5 w-full" />
|
|
||||||
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
|
|
||||||
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,126 +1,29 @@
|
|||||||
import Link from "next/link";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
|
||||||
import { verifySession } from "@/lib/session";
|
|
||||||
import { appDb, schema } from "@/lib/appdb";
|
|
||||||
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
|
||||||
import { odemeyiSonuclandir } from "@/lib/odeme";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { RaporYokCta } from "./rapor-yok-cta";
|
import {
|
||||||
|
OdemeSonucKarti,
|
||||||
|
OdemeSonucKartiSkeleton,
|
||||||
|
} from "@/features/odeme/components/odeme-sonuc-karti";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Ödeme Sonucu",
|
title: "Ödeme Sonucu",
|
||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function OdemeSonucPage({
|
export default function OdemeSonucPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ siparis?: string }>;
|
searchParams: Promise<{ siparis?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { siparis } = await searchParams;
|
|
||||||
// Oturum düşmüşse geri dönüşte ?siparis= kaybolmasın (başarı/unblur deneyimi)
|
|
||||||
const geriDonus = siparis
|
|
||||||
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
|
|
||||||
: "/paket";
|
|
||||||
const session = await verifySession(geriDonus);
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
|
||||||
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
|
|
||||||
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
|
|
||||||
const listeHref = rapor ? "/listem?acildi=1" : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
<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">
|
<main className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16 text-center">
|
||||||
{basarili ? (
|
<Suspense fallback={<OdemeSonucKartiSkeleton />}>
|
||||||
<div className="rounded-2xl border border-emerald-200 bg-white p-8 shadow-sm">
|
{searchParams.then(({ siparis }) => (
|
||||||
<CheckCircle2
|
<OdemeSonucKarti siparis={siparis} />
|
||||||
className="mx-auto size-12 text-emerald-500"
|
))}
|
||||||
aria-hidden
|
</Suspense>
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
<PagePixelDivider seed={79} className="mx-auto mt-5" />
|
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
|
||||||
{listeHref ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
<Link href={listeHref}>
|
|
||||||
Listeni aç — kilidi kaldırdık
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<p className="text-center text-xs text-slate-500">
|
|
||||||
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<RaporYokCta />
|
|
||||||
)}
|
|
||||||
</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{" "}
|
|
||||||
<Link href="/iletisim" className="underline">
|
|
||||||
bizimle iletişime geç
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<PagePixelDivider seed={83} className="mx-auto mt-5" />
|
|
||||||
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
|
|
||||||
<Link href="/paket">Tekrar dene</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
|
|
||||||
// /paket dinamik (kullanıcı kredisi) — statik kabuk için sayfa geometrisine
|
|
||||||
// uygun iskelet: ortalanmış başlık bloğu + paket kartı şeridi.
|
|
||||||
export default function Loading() {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
aria-live="polite"
|
|
||||||
className="mx-auto w-full max-w-3xl flex-1 px-4 py-16"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center text-center">
|
|
||||||
<Skeleton className="h-6 w-56 rounded-full" />
|
|
||||||
<Skeleton className="mt-4 h-10 w-3/4 max-w-xl" />
|
|
||||||
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
|
|
||||||
<Skeleton className="mt-2 h-5 w-1/2 max-w-md" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]">
|
|
||||||
<Skeleton className="h-80 w-full rounded-2xl" />
|
|
||||||
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import Image from "next/image";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
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 { SectionEyebrow } from "@/components/pixel-decor";
|
import { SectionEyebrow } from "@/components/pixel-decor";
|
||||||
import { Parallax } from "@/components/parallax";
|
import { Parallax } from "@/components/parallax";
|
||||||
import { SatinAlForm } from "./satin-al-form";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
import {
|
||||||
|
PaketSatinAl,
|
||||||
|
PaketSatinAlSkeleton,
|
||||||
|
} from "@/features/odeme/components/paket-satinal";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tercih Dönemi Paketi",
|
title: "Tercih Dönemi Paketi",
|
||||||
@@ -26,193 +16,57 @@ export const metadata: Metadata = {
|
|||||||
alternates: { canonical: "/paket" },
|
alternates: { canonical: "/paket" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function tl(kurus: number) {
|
export default function PaketPage({
|
||||||
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} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default async function PaketPage({
|
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ hata?: string }>;
|
searchParams: Promise<{ hata?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const [u, { hata }] = await Promise.all([getCurrentUser(), searchParams]);
|
|
||||||
// Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner
|
|
||||||
const odemeHatasi = hata === "token" || hata === "siparis";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-slate-50 text-slate-900">
|
<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">
|
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-16">
|
||||||
{odemeHatasi ? (
|
{/* Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile
|
||||||
<div
|
döner; hata yokken hiçbir şey çizilmez — fallback bilerek boş. */}
|
||||||
role="alert"
|
<Suspense fallback={null}>
|
||||||
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
{searchParams.then(({ hata }) =>
|
||||||
>
|
hata === "token" || hata === "siparis" ? (
|
||||||
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin birkaç
|
<div
|
||||||
dakika içinde otomatik tanımlanır; tanımlanmazsa aşağıdan tekrar
|
role="alert"
|
||||||
deneyebilirsin — çekim yapılmadıysa hiçbir ücret ödemedin.
|
className="mb-8 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
||||||
</div>
|
>
|
||||||
) : null}
|
Ödemen doğrulanamadı. Kartından çekim yapıldıysa kredilerin
|
||||||
<Parallax fromStart strength={12} className="text-center">
|
birkaç dakika içinde otomatik tanımlanır; tanımlanmazsa
|
||||||
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
|
aşağıdan tekrar deneyebilirsin — çekim yapılmadıysa hiçbir
|
||||||
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
ücret ödemedin.
|
||||||
İ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>
|
|
||||||
</Parallax>
|
|
||||||
|
|
||||||
<Parallax
|
|
||||||
strength={6}
|
|
||||||
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>
|
</div>
|
||||||
<ul className="mt-6 space-y-3">
|
) : null,
|
||||||
{OZELLIKLER.map((o) => (
|
)}
|
||||||
<li key={o.text} className="flex items-start gap-3 text-sm">
|
</Suspense>
|
||||||
<o.icon
|
<Parallax fromStart strength={12} className="text-center">
|
||||||
className="mt-0.5 size-4 shrink-0 text-emerald-600"
|
<SectionEyebrow>Tek seferlik ödeme · Abonelik yok</SectionEyebrow>
|
||||||
aria-hidden
|
<h1 className="mt-4 font-heading text-3xl font-bold sm:text-4xl">
|
||||||
/>
|
İnsan danışmanın işini,{" "}
|
||||||
{o.text}
|
<span className="text-primary">onda bir fiyatına</span>
|
||||||
</li>
|
</h1>
|
||||||
))}
|
<p className="mx-auto mt-3 max-w-xl text-slate-600">
|
||||||
</ul>
|
Tercih danışmanları bu dönemde 2.000–10.000 TL alıyor. KolayTercih
|
||||||
<div className="mt-8">
|
aynı işi gerçek YÖK Atlas verisiyle, dakikalar içinde yapar.
|
||||||
{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
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<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 — sonra {tl(URUNLER.paket.amountKurus)} TL'ye
|
|
||||||
al
|
|
||||||
<ArrowRight className="size-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<p className="text-center text-xs text-slate-500">
|
|
||||||
Girişte {DENEME_KREDISI} deneme kredisi de tanımlanır.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Image
|
|
||||||
src="/odeme/iyzico-logo-band.svg"
|
|
||||||
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
|
|
||||||
width={429}
|
|
||||||
height={32}
|
|
||||||
className="mx-auto mt-5 h-5 w-auto"
|
|
||||||
/>
|
|
||||||
<p className="mt-3 text-center text-xs text-slate-500">
|
|
||||||
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
|
|
||||||
<Link
|
|
||||||
href="/kosullar#iade"
|
|
||||||
className="underline hover:text-slate-700"
|
|
||||||
>
|
|
||||||
İade koşulları
|
|
||||||
</Link>
|
|
||||||
</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">
|
|
||||||
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
|
|
||||||
yine maskeli liste görür. Satın alma yalnızca paketliye
|
|
||||||
açık; paketsize durum dürüstçe açıklanır. */}
|
|
||||||
{u?.hasPaket ? (
|
|
||||||
<SatinAlForm
|
|
||||||
urun="topup"
|
|
||||||
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
|
|
||||||
/>
|
|
||||||
) : u ? (
|
|
||||||
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
|
|
||||||
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket
|
|
||||||
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
|
|
||||||
bitince buradan yükleyebilirsin.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 w-full cursor-pointer"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/giris?callback=${encodeURIComponent("/paket")}`}
|
|
||||||
>
|
|
||||||
Önce giriş yap
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Parallax>
|
|
||||||
|
|
||||||
<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. Sorun mu var?{" "}
|
|
||||||
<Link href="/iletisim" className="underline hover:text-slate-700">
|
|
||||||
Bize yaz
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</Parallax>
|
||||||
|
|
||||||
|
<Suspense fallback={<PaketSatinAlSkeleton />}>
|
||||||
|
<PaketSatinAl />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<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. Sorun mu var?{" "}
|
||||||
|
<Link href="/iletisim" className="underline hover:text-slate-700">
|
||||||
|
Bize yaz
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
121
src/features/odeme/components/odeme-sonuc-karti.tsx
Normal file
121
src/features/odeme/components/odeme-sonuc-karti.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import { verifySession } from "@/lib/session";
|
||||||
|
import { siparisGetir } from "../odeme-queries";
|
||||||
|
import { kullanicininRaporu } from "@/features/rapor/rapor-queries";
|
||||||
|
import { odemeyiSonuclandir } from "@/lib/odeme";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { RaporYokCta } from "./rapor-yok-cta";
|
||||||
|
|
||||||
|
export async function OdemeSonucKarti({ siparis }: { siparis?: string }) {
|
||||||
|
// Oturum düşmüşse geri dönüşte ?siparis= kaybolmasın (başarı/unblur deneyimi)
|
||||||
|
const geriDonus = siparis
|
||||||
|
? `/odeme/sonuc?siparis=${encodeURIComponent(siparis)}`
|
||||||
|
: "/paket";
|
||||||
|
const session = await verifySession(geriDonus);
|
||||||
|
|
||||||
|
const order = siparis ? await siparisGetir(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";
|
||||||
|
|
||||||
|
// Rapor zaten üretildiyse ödül bir tık uzakta: kullanıcıyı doğrudan
|
||||||
|
// rapor sayfasına döndür ki blur çözülme animasyonuyla tam listeyi görsün.
|
||||||
|
const rapor = basarili ? await kullanicininRaporu(session.user.id) : null;
|
||||||
|
const listeHref = rapor ? "/listem?acildi=1" : null;
|
||||||
|
|
||||||
|
return 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>
|
||||||
|
<PagePixelDivider seed={79} className="mx-auto mt-5" />
|
||||||
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
|
{listeHref ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<Link href={listeHref}>
|
||||||
|
Listeni aç — kilidi kaldırdık
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-slate-500">
|
||||||
|
24 tercihin tamamı, gerekçeler, riskler ve PDF artık açık.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<RaporYokCta />
|
||||||
|
)}
|
||||||
|
</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{" "}
|
||||||
|
<Link href="/iletisim" className="underline">
|
||||||
|
bizimle iletişime geç
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<PagePixelDivider seed={83} className="mx-auto mt-5" />
|
||||||
|
<Button asChild variant="outline" className="mt-6 h-11 cursor-pointer">
|
||||||
|
<Link href="/paket">Tekrar dene</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// iyzico doğrulaması beklenirken ortalanmış sonuç kartı iskeleti.
|
||||||
|
export function OdemeSonucKartiSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
|
||||||
|
>
|
||||||
|
<Skeleton className="mx-auto size-12 rounded-full" />
|
||||||
|
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
|
||||||
|
<Skeleton className="mx-auto mt-3 h-5 w-full" />
|
||||||
|
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
|
||||||
|
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/features/odeme/components/paket-satinal.tsx
Normal file
181
src/features/odeme/components/paket-satinal.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
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 { Parallax } from "@/components/parallax";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SatinAlForm } from "./satin-al-form";
|
||||||
|
|
||||||
|
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} Yapay Zeka danışman sorusu (her mesaj 1 kredi)`,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Paket + top-up kartları: kullanıcının paket/kredi durumuna göre CTA'lar
|
||||||
|
// değişir, okuma burada yaşar.
|
||||||
|
export async function PaketSatinAl() {
|
||||||
|
const u = await getCurrentUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Parallax
|
||||||
|
strength={6}
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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 — sonra {tl(URUNLER.paket.amountKurus)} TL'ye
|
||||||
|
al
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-slate-500">
|
||||||
|
Girişte {DENEME_KREDISI} deneme kredisi de tanımlanır.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Image
|
||||||
|
src="/odeme/iyzico-logo-band.svg"
|
||||||
|
alt="iyzico ile Öde — Mastercard, Visa, American Express ve Troy ile güvenli ödeme"
|
||||||
|
width={429}
|
||||||
|
height={32}
|
||||||
|
className="mx-auto mt-5 h-5 w-auto"
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-center text-xs text-slate-500">
|
||||||
|
iyzico güvenli ödeme · Kart bilgilerin bize hiç ulaşmaz ·{" "}
|
||||||
|
<Link
|
||||||
|
href="/kosullar#iade"
|
||||||
|
className="underline hover:text-slate-700"
|
||||||
|
>
|
||||||
|
İade koşulları
|
||||||
|
</Link>
|
||||||
|
</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">
|
||||||
|
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip
|
||||||
|
yine maskeli liste görür. Satın alma yalnızca paketliye
|
||||||
|
açık; paketsize durum dürüstçe açıklanır. */}
|
||||||
|
{u?.hasPaket ? (
|
||||||
|
<SatinAlForm
|
||||||
|
urun="topup"
|
||||||
|
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
|
||||||
|
/>
|
||||||
|
) : u ? (
|
||||||
|
<p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600">
|
||||||
|
Ek kredi, kilitli analizleri açmaz — o yüzden önce paket
|
||||||
|
gerekir. Paket zaten {URUNLER.paket.credits} kredi içerir;
|
||||||
|
bitince buradan yükleyebilirsin.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 w-full cursor-pointer"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/giris?callback=${encodeURIComponent("/paket")}`}
|
||||||
|
>
|
||||||
|
Önce giriş yap
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Parallax>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaketSatinAlSkeleton() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-80 w-full rounded-2xl" />
|
||||||
|
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { ArrowRight } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import { baslatOdeme, type OdemeBaslatDurum } from "./actions";
|
import { baslatOdeme, type OdemeBaslatDurum } from "../odeme-actions";
|
||||||
|
|
||||||
export function SatinAlForm({
|
export function SatinAlForm({
|
||||||
urun,
|
urun,
|
||||||
11
src/features/odeme/odeme-queries.ts
Normal file
11
src/features/odeme/odeme-queries.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { appDb, schema } from "@/lib/appdb";
|
||||||
|
|
||||||
|
// Ödeme feature'ının sorgu kapısı. Sahiplik kontrolü çağıran tarafta —
|
||||||
|
// sipariş yalnızca kendi kullanıcısına gösterilir.
|
||||||
|
export function siparisGetir(siparisId: string) {
|
||||||
|
return appDb.query.orders.findFirst({
|
||||||
|
where: eq(schema.orders.id, siparisId),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user