Merge branch 'is/0923-harita-ustte' into is/0923-birlesik

This commit is contained in:
bilalgursen
2026-09-23 11:27:46 +03:00
7 changed files with 117 additions and 33 deletions

View File

@@ -11,7 +11,7 @@ import type { HaritaPin } from "@/features/liste/components/tercih-haritasi";
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
import { yurtdisiPinKaydet, type YurtdisiPin } from "@/lib/harita-pinler";
import type { RiskSeviyesi } from "@/lib/risk";
import { useManuelListe } from "../liste-store";
import { useListeHidreMi, useManuelListe } from "../liste-store";
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
@@ -20,8 +20,9 @@ const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
riskli: 2,
};
export function ManuelHarita() {
export function ManuelHarita({ yerAyir = false }: { yerAyir?: boolean }) {
const liste = useManuelListe();
const hidre = useListeHidreMi();
const [seciliIl, setSeciliIl] = useState<string | null>(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 <section>'ı 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 (
<div className="mt-16">
<div>
<TercihHaritasiLazy
pinler={pinler}
seciliIl={seciliIl}

View File

@@ -4,6 +4,10 @@
// çizileceği zaman insin: dynamic + ssr'siz. İskelet, haritanın gerçek
// oranını (viewBox 1050 × 447, bkz. tercih-haritasi TAM_GORUNUM) rezerve
// eder — chunk gelince yerleşim zıplamaz.
//
// motion-reduce:animate-none: iskelet artık /sonuc'ta katlamanın üstünde
// duruyor; hareketi azaltılmış isteyen kullanıcıya sürekli, durdurulamaz
// nabız gösterilmez.
import dynamic from "next/dynamic";
@@ -11,7 +15,7 @@ export function HaritaIskeleti() {
return (
<div
aria-hidden
className="aspect-[1050/447] w-full animate-pulse rounded-xl bg-slate-100"
className="aspect-[1050/447] w-full animate-pulse rounded-xl bg-slate-100 motion-reduce:animate-none"
/>
);
}

View File

@@ -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: () => <div className="mt-3 h-26" aria-hidden /> },
);

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -120,9 +120,11 @@ export function TadimlikSatiri({
const girisUrl = `/giris?callback=${encodeURIComponent(listemUretHref("tadimlik"))}`;
return (
// first:mt-0 — /sonuc'ta bu <section> 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 <section>'ı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.
<section ref={gorunumRef} className="mt-16 min-w-0 first:mt-0">