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 { SectionEyebrow } from "@/components/pixel-decor";
import { Parallax } from "@/components/parallax"; import { Parallax } from "@/components/parallax";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
import { import { PaketSatinAl } from "@/features/odeme/components/paket-satinal";
PaketSatinAl,
PaketSatinAlSkeleton,
} from "@/features/odeme/components/paket-satinal";
import { sayfaMetadata } from "@/lib/seo"; import { sayfaMetadata } from "@/lib/seo";
export const metadata: Metadata = sayfaMetadata({ export const metadata: Metadata = sayfaMetadata({
@@ -57,9 +54,9 @@ export default function PaketPage({
</p> </p>
</Parallax> </Parallax>
<Suspense fallback={<PaketSatinAlSkeleton />}> {/* Kart gövdesi statik kabukta; yalnız CTA alanları içeride kendi
<PaketSatinAl /> Suspense sınırlarıyla istek anında gelir (bkz. paket-satinal.tsx). */}
</Suspense> <PaketSatinAl />
<p className="mt-10 text-center text-xs leading-relaxed text-slate-500"> <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 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 Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -11,7 +12,8 @@ import {
ShieldCheck, ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { getCurrentUser } from "@/lib/session"; 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 { VERI_YIL_SAYISI } from "@/lib/veri-kapsami";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Parallax } from "@/components/parallax"; import { Parallax } from "@/components/parallax";
@@ -39,11 +41,17 @@ const OZELLIKLER = [
}, },
] as const; ] as const;
// Paket + top-up kartları: kullanıcının paket/kredi durumuna göre CTA'lar // Paket + top-up kartları. Kartların gövdesi (başlık, fiyat, özellikler,
// değişir, okuma burada yaşar. // iyzico bandı) STATİK kabukta kalır; kullanıcıya bağlı olan yalnızca iki küçük
export async function PaketSatinAl() { // CTA alanıdır (PaketCta, TopupCta) ve her biri kendi Suspense sınırındadır.
const u = await getCurrentUser(); //
// 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 ( return (
<Parallax <Parallax
strength={6} strength={6}
@@ -71,44 +79,9 @@ export async function PaketSatinAl() {
))} ))}
</ul> </ul>
<div className="mt-8"> <div className="mt-8">
{u ? ( <Suspense fallback={<Skeleton className="h-12 w-full rounded-full" />}>
u.hasPaket ? ( <PaketCta />
<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"> </Suspense>
<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>
)}
</div> </div>
<Image <Image
src="/odeme/iyzico-logo-band.svg" src="/odeme/iyzico-logo-band.svg"
@@ -139,47 +112,95 @@ export async function PaketSatinAl() {
{tl(URUNLER.topup.amountKurus)} TL. {tl(URUNLER.topup.amountKurus)} TL.
</p> </p>
<div className="mt-4"> <div className="mt-4">
{/* Top-up hasPaket vermez: paketsiz biri alırsa parasını verip <Suspense fallback={<Skeleton className="h-11 w-full rounded-full" />}>
yine maskeli liste görür. Satın alma yalnızca paketliye <TopupCta />
açık; paketsize durum dürüstçe açıklanır. */} </Suspense>
{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> </div>
</section> </section>
</Parallax> </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 ( return (
<div <SatinAlForm
aria-live="polite" urun="paket"
className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]" label={`${tl(URUNLER.paket.amountKurus)} TL — Paketi al`}
> vurgulu
<Skeleton className="h-80 w-full rounded-2xl" /> />
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" /> );
</div> }
/** 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>
); );
} }