From ea4b30a8429350b0d94030772a07ebc030ea1216 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Wed, 23 Sep 2026 10:58:53 +0300 Subject: [PATCH] =?UTF-8?q?fix(sonuc):=20harita=20en=20=C3=BCste=20=C3=A7?= =?UTF-8?q?=C4=B1kar,=20liste=20say=C4=B1s=C4=B1=20=C3=A7erezi=20kaymay?= =?UTF-8?q?=C4=B1=20s=C4=B1f=C4=B1rlar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manuel listenin haritası /sonuc'ta tablonun altından sayfanın en üstüne alındı: listesini kurmuş ziyaretçi ekranda önce kendi listesini görür. Liste boşken harita DOM'a hiç girmez — yer tutucu da çizilmez, tadımlık main'in ilk çocuğu kalır ve first:mt-0 çalışmayı sürdürür. Yani ürünü ilk kez gören ziyaretçide sayfa sırası hiç değişmiyor. Çerez neden gerekti: manuel liste localStorage'ta yaşıyor, store'un sunucu snapshot'ı her zaman boş liste dönüyor. Harita yukarıdayken kayıtlı liste ancak hidrasyondan sonra okunabildiği için harita sonradan belirip sayfayı itiyordu (mobilde ~146 px, masaüstünde ~368 px; CLS ~0,13 / ~0,35). Client artık listenin yalnızca UZUNLUĞUNU kolaytercih.liste-sayi çerezine aynalıyor (kişisel veri ya da program kimliği yok, öznitelikleri kolaytercih.profil ile birebir aynı); sunucu bunu okuyup haritaya ilk boyada tam ölçülü yer ayırıyor, chunk inince kutunun yalnızca içi doluyor. Çerez hidrasyona kadar geçerli: store okunduktan sonra tek doğru kaynak listenin kendisi, böylece bayat çerez ya da çekmeceden boşaltılan liste durumunda boş harita ekranda kalmıyor. Yanında: harita iskeletine motion-reduce:animate-none (artık katlamanın üstünde, durdurulamaz nabız gösterilmemeli), yurt dışı şeridine sabit yükseklikli loading yer tutucu (ssr:false + loading'siz dynamic, yoktan beliren ~104 px). Kararın tersini belgeleyen üç yorum bloğu da güncellendi. Co-Authored-By: Claude Opus 5 --- src/app/sonuc/page.tsx | 47 ++++++++++++------- .../liste/components/manuel-harita.tsx | 37 ++++++++++----- .../liste/components/tercih-haritasi-lazy.tsx | 6 ++- .../components/yurtdisi-haritalar-lazy.tsx | 9 +++- src/features/liste/liste-dogrula.ts | 10 ++++ src/features/liste/liste-store.ts | 33 +++++++++++++ .../rapor/components/tadimlik-satiri.tsx | 8 ++-- 7 files changed, 117 insertions(+), 33 deletions(-) diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx index b30fe24..dbd7600 100644 --- a/src/app/sonuc/page.tsx +++ b/src/app/sonuc/page.tsx @@ -11,6 +11,7 @@ import { SonucProgramTablosuSkeleton, } from "@/features/liste/components/sonuc-program-tablosu"; import { ManuelHarita } from "@/features/liste/components/manuel-harita"; +import { LISTE_SAYI_CEREZ_ADI } from "@/features/liste/liste-dogrula"; import { TadimlikBolumu } from "@/features/rapor/components/tadimlik-bolumu"; import { SonucSihirbazi } from "@/features/sihirbaz/components/sihirbaz-bolumu"; import { SiraGerekli } from "@/features/sihirbaz/components/sira-gerekli"; @@ -85,6 +86,17 @@ export default function SonucPage({ const uniturGrubu = tipHam === "devlet" || tipHam === "vakif" ? tipHam : undefined; + // Manuel liste localStorage'ta yaşar; store'un sunucu snapshot'ı her + // zaman boştur. Client, listenin yalnızca UZUNLUĞUNU çereze aynalar + // (liste-store.ts) — sunucunun "bu ziyaretçinin listesi var" bilgisini + // hidrasyondan ÖNCE alabildiği tek kanal bu. Sayı > 0 ise haritaya tam + // ölçülü yer ayrılır; çerez yoksa/0 ise harita hiç render edilmez. + const listeSayisi = Number.parseInt( + cerezler.get(LISTE_SAYI_CEREZ_ADI)?.value ?? "", + 10, + ); + const haritayaYerAyir = Number.isFinite(listeSayisi) && listeSayisi > 0; + if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { return ( <> @@ -97,21 +109,26 @@ export default function SonucPage({ return (
- {/* Sayfa hiyerarşisi: tadımlık → ham tablo → manuel listenin - haritası. Tadımlık, ödeme öncesi gösterebildiğimiz TEK gerçek - önizleme olduğu için ilk ekranda durur. + {/* Sayfa hiyerarşisi: manuel listenin haritası → tadımlık → ham + tablo. İki farklı ziyaretçi, iki farklı ilk ekran: - Harita neden TABLONUN ALTINDA: liste store'unun sunucu - snapshot'ı her zaman boş liste döner (liste-store.ts), kayıtlı - liste ancak hidrasyondan sonra okunur. Harita yukarıda olsaydı - dönüş ziyaretçisinde sayfa açıldıktan saniyeler sonra ~368 px - yüksekliğinde belirip tadımlığı ve tabloyu aşağı itecekti - (impact fraction ~1, CLS eşiğinin çok üstü). Aşağıda belirince - yalnızca sihirbaz ile dipnotu iter, ikisi de katlanın altında. - Bilgi mimarisi olarak da doğru sıra: harita, tablodan kurulan - listenin görselleştirmesidir. Liste boşken hiç render edilmez - (bkz. manuel-harita.tsx); store'a abone olduğu için tablodan - "+" ile eklenenler buraya canlı yansır. */} + • Listesi OLAN ziyaretçi ekranda önce kendi kurduğu listeyi + görür — o kişi "bu ürün ne?" aşamasını geçmiştir. + • Listesi OLMAYAN ziyaretçide harita DOM'a hiç girmez + (manuel-harita.tsx), sıra bugünküyle birebir aynı kalır: + tadımlık → tablo. Tadımlık, ödeme öncesi gösterebildiğimiz + TEK gerçek önizleme olduğu için o kişide ilk ekranda durur + ve `first:mt-0` ile main'in py-12'sini kullanmayı sürdürür. + + Harita YUKARIDA nasıl CLS üretmiyor: kayıtlı liste ancak + hidrasyondan sonra localStorage'tan okunabildiği için harita + yukarıdayken sonradan belirip sayfayı iterdi (mobilde ~146 px, + masaüstünde ~368 px). Çözüm, listenin SAYISINI çerezle + sunucuya aynalamak (haritayaYerAyir, yukarısı): sunucu ilk + boyada haritanın tam ölçülü kutusunu çizer, chunk inince + kutunun yalnızca İÇİ dolar. Haritanın yerini bir daha + aşağı almadan önce bu mekanizmayı kontrol et. */} + {/* Girişsiz tadımlık: gerçek rapor satırı formatında tek örnek. Havuzda içerik yoksa bileşen hiç render edilmez. */} @@ -128,8 +145,6 @@ export default function SonucPage({ uniturGrubu={uniturGrubu} /> - - {/* Sihirbaz akışı (CTA + modal) — üretim ve liste /listem'de yaşar, burada yalnızca funnel kartları render edilir */} = { @@ -20,8 +20,9 @@ const RISK_AGIRLIK: Record = { riskli: 2, }; -export function ManuelHarita() { +export function ManuelHarita({ yerAyir = false }: { yerAyir?: boolean }) { const liste = useManuelListe(); + const hidre = useListeHidreMi(); const [seciliIl, setSeciliIl] = useState(null); const { pinler, yurtdisiPinler, haritaDisi } = useMemo(() => { @@ -64,18 +65,30 @@ export function ManuelHarita() { }; }, [liste]); - // Liste boşken hiç yer tutulmaz: boş harita /sonuc'un ilk ekranını işgal - // ediyordu, oysa ödeme öncesi ilk ekranda durması gereken şey tadımlık. - // İlk "+" ile liste dolunca harita kendiliğinden belirir; 197 KB'lık il - // geometrisi yine ancak o anda iner (TercihHaritasiLazy zaten `dynamic`). - if (liste.length === 0) return null; + // Liste boşken hiç yer tutulmaz: hiç liste kurmamış ziyaretçide harita + // DOM'a girmez, yer tutucu da çizilmez (Bilal'in açık şartı). İlk "+" ile + // liste dolunca harita kendiliğinden belirir. + // + // yerAyir: sunucu, kullanıcının kayıtlı bir listesi OLDUĞUNU biliyorsa + // (LISTE_SAYI_CEREZ_ADI çerezi > 0) bu bayrağı verir. Liste ancak + // hidrasyondan sonra localStorage'tan okunduğu için ilk boyada `liste` hâlâ + // boştur; bayrak olmadan harita sonradan belirip sayfayı iterdi. Boş+yerAyir + // durumunda AYRI bir iskelet dalı yazmıyoruz: fonksiyon akar, `pinler` boş + // dizi olur ve TercihHaritasiLazy chunk inene kadar zaten HaritaIskeleti'ni + // gösterir — böylece yer tutucu ile gerçek harita aynı kutudan gelir + // (yükseklik ayrışamaz) ve 197 KB'lık chunk hidrasyonu beklemeden inmeye + // başlar. + // + // yerAyir yalnızca hidrasyona KADAR geçerlidir: store okunduktan sonra tek + // doğru kaynak listenin kendisidir. Böylece hem bayat çerez (localStorage + // silinmiş, çerez kalmış) hem de kullanıcının çekmeceden listeyi boşaltması + // durumunda boş harita ekranda kalmaz. + if (liste.length === 0 && (hidre || !yerAyir)) return null; - // mt-16: /sonuc'ta harita artık tablonun ALTINDA duruyor (bkz. sonuc/page.tsx) - // ve sayfanın bölüm ritmini paylaşıyor — tablo
'ı ve sihirbaz - // kartları da mt-16 kullanır. Eskiden main'in ilk çocuğu olduğu için üst - // boşluğu main'in py-12'sinden alıyordu, kendi margin'i yoktu. + // Margin yok: harita /sonuc'ta main'in İLK çocuğudur, üst boşluğunu + // main'in py-12'sinden alır (bkz. sonuc/page.tsx). return ( -
+
); } diff --git a/src/features/liste/components/yurtdisi-haritalar-lazy.tsx b/src/features/liste/components/yurtdisi-haritalar-lazy.tsx index fc69096..bf13c92 100644 --- a/src/features/liste/components/yurtdisi-haritalar-lazy.tsx +++ b/src/features/liste/components/yurtdisi-haritalar-lazy.tsx @@ -5,7 +5,14 @@ import dynamic from "next/dynamic"; +// loading: chunk inene kadar şeridin yüksekliğini rezerve eder. Türkiye +// haritası /sonuc'un en üstüne çıktığı için bu şerit de katlamanın üstünde; +// yer tutucusuz hâlinde yoktan belirip altındaki her şeyi iterdi. +// h-26 (104 px) tek satırlık gerçek yüksekliktir: figür w-52 (208 px), +// svg oranı 1050×447 → 88,6 px, figcaption text-xs → 16 px. Kap mt-3'ü +// bileşenin kendi kökündekiyle aynı (yurtdisi-haritalar.tsx). Birden çok +// ülke alt satıra kayarsa fark kalır — sunucu ülke sayısını bilemez. export const YurtdisiHaritalarLazy = dynamic( () => import("./yurtdisi-haritalar").then((m) => m.YurtdisiHaritalar), - { ssr: false }, + { ssr: false, loading: () =>
}, ); diff --git a/src/features/liste/liste-dogrula.ts b/src/features/liste/liste-dogrula.ts index 65674ce..2396a66 100644 --- a/src/features/liste/liste-dogrula.ts +++ b/src/features/liste/liste-dogrula.ts @@ -6,6 +6,16 @@ import type { RiskSeviyesi } from "@/lib/risk"; export const MANUEL_LISTE_MAX = 24; +/** + * Listenin sunucu aynası — yalnızca ELEMAN SAYISI taşır, hiçbir kişisel veri + * ya da program kimliği taşımaz. Liste localStorage'ta yaşadığı için sunucu + * snapshot'ı her zaman boştur; /sonuc bu çerezi okuyup haritaya hidrasyondan + * önce tam ölçülü yer ayırabilsin diye var (bkz. sonuc/page.tsx). + * Sabit burada durur çünkü bu dosya client store ile sunucu tarafının ortak + * zemini; store'un kendisi "use client". + */ +export const LISTE_SAYI_CEREZ_ADI = "kolaytercih.liste-sayi"; + export type ManuelKaynak = "sonuc" | "bolum" | "universite"; export type ManuelTercih = { diff --git a/src/features/liste/liste-store.ts b/src/features/liste/liste-store.ts index 26167dc..5464c92 100644 --- a/src/features/liste/liste-store.ts +++ b/src/features/liste/liste-store.ts @@ -14,6 +14,7 @@ import { } from "@/features/sihirbaz/sihirbaz-sabitler"; import { tercihProfiliOku } from "@/features/sihirbaz/sihirbaz-profil"; import { + LISTE_SAYI_CEREZ_ADI, MANUEL_LISTE_MAX, dogrula, type ManuelKaynak, @@ -39,10 +40,23 @@ function yayinla() { for (const cb of dinleyiciler) cb(); } +/** + * Sunucu aynası: listenin yalnızca UZUNLUĞU çereze yazılır (kişisel veri yok). + * /sonuc bunu okuyup haritaya hidrasyondan önce tam ölçülü yer ayırır; yoksa + * kayıtlı listesi olan ziyaretçide harita sonradan belirip sayfayı iterdi. + * Öznitelikler profilCerezineYaz ile birebir aynı (sihirbaz-profil.ts). + */ +function sayiCereziniYaz(n: number) { + if (typeof document === "undefined") return; + const guvenli = window.location.protocol === "https:" ? "; secure" : ""; + document.cookie = `${LISTE_SAYI_CEREZ_ADI}=${n}; path=/; max-age=31536000; samesite=lax${guvenli}`; +} + function kaydet() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(liste)); } catch {} + sayiCereziniYaz(liste.length); } function yukle() { @@ -52,6 +66,10 @@ function yukle() { const ham = localStorage.getItem(STORAGE_KEY); if (ham) liste = dogrula(JSON.parse(ham)); } catch {} + // Çerezi her açılışta gerçek uzunlukla tazele: yeni dağıtımdan sonra ilk + // ziyarette çerezi doğurur, localStorage silinmiş ama çerez kalmış durumda + // da 0 yazıp /sonuc'un boşuna yer ayırmasını engeller. + sayiCereziniYaz(liste.length); // Profilsiz + ile eklenenlerin riski null'dır. Profil sonradan hangi yoldan // yazılırsa yazılsın (kapı, hero/CTA formu, sihirbaz, giriş senkronu) riskler // dolsun: açılışta bir kez, sonra her profil değişiminde. @@ -62,6 +80,7 @@ function yukle() { if (e.key !== STORAGE_KEY) return; try { liste = e.newValue ? dogrula(JSON.parse(e.newValue)) : []; + sayiCereziniYaz(liste.length); yayinla(); } catch {} }); @@ -87,6 +106,20 @@ export function useManuelListe(): ManuelTercih[] { ); } +/** + * Store'un client'ta hidre olup olmadığı: sunucu snapshot'ı false, hidrasyondan + * sonra true. `liste` ile aynı anda flip eder (ikisi de abone içindeki + * yukle()'den doğar), bu yüzden "boş liste" ile "henüz okunmadı" ayrımı + * gereken yerlerde tek render'da tutarlıdır — bkz. ManuelHarita. + */ +export function useListeHidreMi(): boolean { + return useSyncExternalStore( + abone, + () => yuklendi, + () => false, + ); +} + export function useCekmeceAcik(): boolean { return useSyncExternalStore( abone, diff --git a/src/features/rapor/components/tadimlik-satiri.tsx b/src/features/rapor/components/tadimlik-satiri.tsx index 7caa8ec..276b5b1 100644 --- a/src/features/rapor/components/tadimlik-satiri.tsx +++ b/src/features/rapor/components/tadimlik-satiri.tsx @@ -120,9 +120,11 @@ export function TadimlikSatiri({ const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`; return ( - // first:mt-0 — /sonuc'ta bu
main'in ilk DOM çocuğudur (harita - // artık tablonun altında, üstte hiçbir kardeş yok; JSX yorumları düğüm - // üretmez): main'in py-12'si tek başına yeterli üst boşluğu verir. + // first:mt-0 — /sonuc'ta harita bu
'ın ÜSTÜNDE durur ama yalnızca + // kayıtlı listesi olan ziyaretçide DOM'a girer (manuel-harita.tsx). Liste + // yoksa tadımlık main'in ilk DOM çocuğudur (JSX yorumları düğüm üretmez) ve + // main'in py-12'si tek başına yeterli üst boşluğu verir; harita varsa + // tadımlık ikinci çocuk olur ve bölüm ritmini veren mt-16'yı alır. // Tadımlığın render edilmediği durumlarda (girişli kullanıcı, boş havuz) // bu bileşen hiç dönmediği için kural ilk gerçek bölüme geçer.