fix(paket): girişsiz ziyaretçide boş sayfa — kart gövdesi statik kabukta, yalnız CTA dinamik

Kök neden: React Flight ~3,2 KB'ı aşan dinamik parçayı ayrı satırlara bölüyor;
PPR resume bunlara verdiği segment kimliğini kabukta geç tamamlanan sınırın
(kök loading.tsx) kimliğiyle çakıştırıyor (S:3 iki kez) → HierarchyRequestError.
Lokal prod build'de yeniden üretildi, düzeltmeden sonra 5/5 temiz.
Girişsiz CTA metni: "ilk 3 tercihini ücretsiz gör" (sayılar sabitlerden).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 23:34:52 +03:00
parent 9e9de84a7e
commit 95bc87e5f7
2 changed files with 104 additions and 86 deletions

View File

@@ -4,10 +4,7 @@ import type { Metadata } from "next";
import { SectionEyebrow } from "@/components/pixel-decor";
import { Parallax } from "@/components/parallax";
import { SiteFooter } from "@/components/site-footer";
import {
PaketSatinAl,
PaketSatinAlSkeleton,
} from "@/features/odeme/components/paket-satinal";
import { PaketSatinAl } from "@/features/odeme/components/paket-satinal";
import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = sayfaMetadata({
@@ -57,9 +54,9 @@ export default function PaketPage({
</p>
</Parallax>
<Suspense fallback={<PaketSatinAlSkeleton />}>
<PaketSatinAl />
</Suspense>
{/* Kart gövdesi statik kabukta; yalnız CTA alanları içeride kendi
Suspense sınırlarıyla istek anında gelir (bkz. paket-satinal.tsx). */}
<PaketSatinAl />
<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

View File

@@ -1,3 +1,4 @@
import { Suspense } from "react";
import Image from "next/image";
import Link from "next/link";
import {
@@ -11,7 +12,8 @@ import {
ShieldCheck,
} from "lucide-react";
import { getCurrentUser } from "@/lib/session";
import { URUNLER, DENEME_KREDISI } from "@/lib/credits";
import { URUNLER, DENEME_KREDISI, RAPOR_KREDI } from "@/lib/credits";
import { ACIK_SATIR } from "@/lib/rapor-maske";
import { VERI_YIL_SAYISI } from "@/lib/veri-kapsami";
import { Button } from "@/components/ui/button";
import { Parallax } from "@/components/parallax";
@@ -39,11 +41,17 @@ const OZELLIKLER = [
},
] 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();
// Paket + top-up kartları. Kartların gövdesi (başlık, fiyat, özellikler,
// iyzico bandı) STATİK kabukta kalır; kullanıcıya bağlı olan yalnızca iki küçük
// CTA alanıdır (PaketCta, TopupCta) ve her biri kendi Suspense sınırındadır.
//
// Neden bu kadar küçük parçalar: React Flight ~3,2 KB'ı aşan bir dinamik
// parçanın devamını ayrı satırlara böler; PPR resume bunlara yeni segment
// kimliği verirken kabukta geç tamamlanan sınırın (kök loading.tsx) kimliğiyle
// çakışıyor (Next 16.2 / React 19.2) ve tarayıcıda HierarchyRequestError ile
// sayfa boş kalıyordu. Dinamik parçayı bu sınırın altında tut: kartın gövdesini
// yeniden bu async bileşenlerin içine TAŞIMA.
export function PaketSatinAl() {
return (
<Parallax
strength={6}
@@ -71,44 +79,9 @@ export async function PaketSatinAl() {
))}
</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 —{" "}
{/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
de yoksa boş durum sihirbaza yönlendirir. */}
<Link href="/listem?uret=1" 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&apos;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>
)}
<Suspense fallback={<Skeleton className="h-12 w-full rounded-full" />}>
<PaketCta />
</Suspense>
</div>
<Image
src="/odeme/iyzico-logo-band.svg"
@@ -139,47 +112,95 @@ export async function PaketSatinAl() {
{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>
)}
<Suspense fallback={<Skeleton className="h-11 w-full rounded-full" />}>
<TopupCta />
</Suspense>
</div>
</section>
</Parallax>
);
}
export function PaketSatinAlSkeleton() {
/** Ana paket kartının kullanıcıya bağlı CTA'sı — bilerek küçük (bkz. üstteki not). */
async function PaketCta() {
const u = await getCurrentUser();
if (!u) {
return (
<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 — ilk {ACIK_SATIR} tercihini ücretsiz gör
<ArrowRight className="size-4" aria-hidden />
</Link>
</Button>
<p className="text-center text-xs text-slate-500">
Girişte {DENEME_KREDISI} deneme kredisi tanımlanır; liste{" "}
{RAPOR_KREDI} kredi, ilk {ACIK_SATIR} satır açık. Paketi sonra
alırsın.
</p>
</div>
);
}
if (u.hasPaket) {
return (
<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 —{" "}
{/* ?uret=1: rapor varsa /listem raporu gösterir (bayrak yok
sayılır), yoksa bekleyen seçimlerle üretim başlar; seçim
de yoksa boş durum sihirbaza yönlendirir. */}
<Link href="/listem?uret=1" className="underline">
listeni oluştur
</Link>
</div>
);
}
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>
<SatinAlForm
urun="paket"
label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
vurgulu
/>
);
}
/** Top-up kartının kullanıcıya bağlı CTA'sı — bilerek küçük (bkz. üstteki not). */
async function TopupCta() {
const u = await getCurrentUser();
// 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.
if (u?.hasPaket) {
return (
<SatinAlForm
urun="topup"
label={`+${URUNLER.topup.credits} kredi — ${tl(URUNLER.topup.amountKurus)} TL`}
/>
);
}
if (u) {
return (
<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>
);
}
return (
<Button asChild variant="outline" className="h-11 w-full cursor-pointer">
<Link href={`/giris?callback=${encodeURIComponent("/paket")}`}>
Önce giriş yap
</Link>
</Button>
);
}