refactor: güncellenmiş profil çerezi ile sonuç sayfası yönlendirmeleri
Some checks failed
Deploy / deploy (push) Has been cancelled

Profil çerezinin SSR'da kullanılmasını sağlayarak, sonuç sayfasındaki yönlendirmeleri güncelledik. Artık profil bilgileri URL parametreleri yerine çerezden alınarak işleniyor. Ayrıca, profil çerezi olmayan kullanıcılar için yönlendirme mantığı iyileştirildi. Çeşitli bileşenlerdeki URL oluşturma fonksiyonları güncellenerek, profil bilgileri çerezden okunacak şekilde düzenlendi. Bu değişiklikler, kullanıcı deneyimini artırmayı ve daha tutarlı bir veri akışı sağlamayı hedefliyor.
This commit is contained in:
bilalgursen
2026-08-09 03:31:42 +03:00
parent 3a8c049092
commit ce6871d623
26 changed files with 702 additions and 145 deletions

209
scripts/ulke-harita-uret.ts Normal file
View File

@@ -0,0 +1,209 @@
// Yurtdışı üniversite sayfaları için ülke kontürlerini üretir.
//
// Kaynak: Natural Earth 10m admin_0_countries (kamu malı). GeoJSON bir kez
// indirilip tmp'ye önbelleklenir; çıktı src/lib/harita-ulkeler-verisi.ts
// dosyasına yazılır ve repo'ya commit'lenir (build ağa çıkmaz).
//
// Projeksiyon: ülke başına eşdikdörtgen (plate carrée) + cos(ortaEnlem)
// düzeltmesi; Türkiye haritasının kırpılmış oranıyla aynı 1050×447 kutuya
// sığdırılır. Nokta seyreltme ~1px eşikli olduğundan çıktı küçük kalır.
//
// Çalıştırma: pnpm tsx scripts/ulke-harita-uret.ts
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const GENISLIK = 1050;
const YUKSEKLIK = 447;
const KENAR_PAYI = 26;
const SEYRELTME_PX = 1.1;
const MIN_HALKA_PX = 3; // bbox'u bundan küçük halkalar (adacıklar) atlanır
const KAYNAK_URL =
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson";
type UlkeTanim = {
kod: string;
ad: string;
/** Vurgulanan (mavi) NE ADMIN adları */
vurgulu: string[];
/** Soluk çizilen bağlam parçaları (ör. Kıbrıs'ın güneyi) */
baglam?: string[];
};
const ULKELER: UlkeTanim[] = [
{
kod: "kktc",
ad: "KKTC",
vurgulu: ["Northern Cyprus"],
baglam: [
"Cyprus",
"Cyprus No Mans Area",
"Dhekelia Sovereign Base Area",
"Akrotiri Sovereign Base Area",
],
},
{ kod: "azerbaycan", ad: "Azerbaycan", vurgulu: ["Azerbaijan"] },
{ kod: "kirgizistan", ad: "Kırgızistan", vurgulu: ["Kyrgyzstan"] },
{ kod: "kazakistan", ad: "Kazakistan", vurgulu: ["Kazakhstan"] },
{
kod: "bosna-hersek",
ad: "Bosna-Hersek",
vurgulu: ["Bosnia and Herzegovina"],
},
{ kod: "makedonya", ad: "Kuzey Makedonya", vurgulu: ["North Macedonia"] },
{ kod: "arnavutluk", ad: "Arnavutluk", vurgulu: ["Albania"] },
];
type Halka = [number, number][]; // [lng, lat]
async function geojsonGetir(): Promise<{
features: {
properties: { ADMIN: string };
geometry: { type: string; coordinates: unknown };
}[];
}> {
const cachePath = path.join(os.tmpdir(), "ne_10m_admin_0_countries.geojson");
if (!fs.existsSync(cachePath)) {
console.log("indiriliyor:", KAYNAK_URL);
const res = await fetch(KAYNAK_URL);
if (!res.ok) throw new Error(`indirme başarısız: ${res.status}`);
fs.writeFileSync(cachePath, Buffer.from(await res.arrayBuffer()));
}
return JSON.parse(fs.readFileSync(cachePath, "utf8"));
}
function halkalar(geometry: { type: string; coordinates: unknown }): Halka[] {
if (geometry.type === "Polygon") return geometry.coordinates as Halka[];
if (geometry.type === "MultiPolygon")
return (geometry.coordinates as Halka[][]).flat();
throw new Error(`beklenmeyen geometri: ${geometry.type}`);
}
function pathUret(
ringler: Halka[],
proje: (lng: number, lat: number) => { x: number; y: number },
): string[] {
const parcalar: string[] = [];
for (const ring of ringler) {
const pts = ring.map(([lng, lat]) => proje(lng, lat));
const xs = pts.map((p) => p.x);
const ys = pts.map((p) => p.y);
if (
Math.max(...xs) - Math.min(...xs) < MIN_HALKA_PX &&
Math.max(...ys) - Math.min(...ys) < MIN_HALKA_PX
)
continue;
let d = "";
let sonX = NaN;
let sonY = NaN;
for (let i = 0; i < pts.length; i++) {
const { x, y } = pts[i];
if (i > 0 && Math.hypot(x - sonX, y - sonY) < SEYRELTME_PX) continue;
d += `${d ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
sonX = x;
sonY = y;
}
d += "Z";
if (d.length > 10) parcalar.push(d);
}
return parcalar;
}
async function main() {
const geojson = await geojsonGetir();
const byAdmin = new Map(
geojson.features.map((f) => [f.properties.ADMIN, f] as const),
);
const cikti: string[] = [];
const kodlar = ULKELER.map((u) => `"${u.kod}"`).join(" | ");
cikti.push(
"// Bu dosya scripts/ulke-harita-uret.ts tarafından üretildi — elle düzenleme.",
"// Kaynak: Natural Earth 10m admin_0_countries (kamu malı).",
"",
`export type UlkeKodu = ${kodlar};`,
"",
"export type UlkeHarita = {",
" ad: string;",
" /** lat/lng → viewBox koordinatı: x = ax*lng + bx, y = ay*lat + by */",
" proje: { ax: number; bx: number; ay: number; by: number };",
" /** Vurgulanan ülke kontürleri */",
" ulke: string[];",
" /** Soluk çizilen bağlam kontürleri (ör. Kıbrıs adasının güneyi) */",
" baglam: string[];",
"};",
"",
`export const ULKE_HARITA_GENISLIK = ${GENISLIK};`,
`export const ULKE_HARITA_YUKSEKLIK = ${YUKSEKLIK};`,
"",
"export const ULKE_HARITALARI: Record<UlkeKodu, UlkeHarita> = {",
);
for (const ulke of ULKELER) {
const adlar = [...ulke.vurgulu, ...(ulke.baglam ?? [])];
const geometriler = adlar.map((ad) => {
const f = byAdmin.get(ad);
if (!f) throw new Error(`Natural Earth'te bulunamadı: ${ad}`);
return { ad, ringler: halkalar(f.geometry) };
});
// Sığdırma: tüm parçaların (bağlam dahil) bbox'una göre tek ölçek
const tumNoktalar = geometriler.flatMap((g) => g.ringler.flat());
const latler = tumNoktalar.map(([, lat]) => lat);
const ortaLat = (Math.min(...latler) + Math.max(...latler)) / 2;
const kx = Math.cos((ortaLat * Math.PI) / 180);
const pxler = tumNoktalar.map(([lng]) => lng * kx);
const pyler = tumNoktalar.map(([, lat]) => -lat);
const minX = Math.min(...pxler);
const maxX = Math.max(...pxler);
const minY = Math.min(...pyler);
const maxY = Math.max(...pyler);
const s = Math.min(
(GENISLIK - 2 * KENAR_PAYI) / (maxX - minX),
(YUKSEKLIK - 2 * KENAR_PAYI) / (maxY - minY),
);
const ox = (GENISLIK - s * (maxX - minX)) / 2;
const oy = (YUKSEKLIK - s * (maxY - minY)) / 2;
const proje = (lng: number, lat: number) => ({
x: s * (lng * kx - minX) + ox,
y: s * (-lat - minY) + oy,
});
const vurguluPaths = geometriler
.filter((g) => ulke.vurgulu.includes(g.ad))
.flatMap((g) => pathUret(g.ringler, proje));
const baglamPaths = geometriler
.filter((g) => !ulke.vurgulu.includes(g.ad))
.flatMap((g) => pathUret(g.ringler, proje));
const yaz = (n: number) => Number(n.toFixed(4));
cikti.push(
` "${ulke.kod}": {`,
` ad: ${JSON.stringify(ulke.ad)},`,
` proje: { ax: ${yaz(s * kx)}, bx: ${yaz(ox - s * minX)}, ay: ${yaz(-s)}, by: ${yaz(oy - s * minY)} },`,
` ulke: ${JSON.stringify(vurguluPaths)},`,
` baglam: ${JSON.stringify(baglamPaths)},`,
" },",
);
console.log(
`${ulke.kod}: ${vurguluPaths.length} vurgulu + ${baglamPaths.length} bağlam path, ` +
`~${Math.round((vurguluPaths.join("").length + baglamPaths.join("").length) / 1024)}KB`,
);
}
cikti.push("};", "");
const hedef = path.join(
process.cwd(),
"src",
"lib",
"harita-ulkeler-verisi.ts",
);
fs.writeFileSync(hedef, cikti.join("\n"));
console.log("yazıldı:", hedef);
}
main();

View File

@@ -36,7 +36,8 @@ export function GET(request: NextRequest) {
return NextResponse.json({ error: "Geçersiz offset." }, { status: 400 }); return NextResponse.json({ error: "Geçersiz offset." }, { status: 400 });
} }
// /sonuc tablosunun filtreleri sayfalamada da korunur (bkz. sonucHref) // /sonuc tablosunun filtreleri (SSR'da profil çerezinden çözülüp tabloya
// prop geçen il/tip) sayfalama XHR'ında query ile korunur
const iller = [ const iller = [
...new Set(searchParams.getAll("il").filter((i) => i.trim().length > 0)), ...new Set(searchParams.getAll("il").filter((i) => i.trim().length > 0)),
].slice(0, 5); ].slice(0, 5);

View File

@@ -1,6 +1,11 @@
import { Suspense } from "react"; import { Suspense } from "react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers";
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas"; import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
import {
cerezdenTercihProfili,
PROFIL_CEREZ_ADI,
} from "@/features/sihirbaz/sihirbaz-sabitler";
import { import {
SonucProgramTablosu, SonucProgramTablosu,
SonucProgramTablosuSkeleton, SonucProgramTablosuSkeleton,
@@ -14,9 +19,9 @@ import { Skeleton } from "@/components/ui/skeleton";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tercih Listeni Kur", title: "Tercih Listeni Kur",
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama // İçerik profil çerezine göre kişisel (eski linklerde param uzayı sonsuz):
// link değeri follow ile geri akar. robots.txt'te ENGELLEME — noindex'in // indexlenmez ama link değeri follow ile geri akar. robots.txt'te
// görülebilmesi için sayfa taranabilir kalmalı. // ENGELLEME — noindex'in görülebilmesi için sayfa taranabilir kalmalı.
robots: { index: false, follow: true }, robots: { index: false, follow: true },
}; };
@@ -47,28 +52,37 @@ export default function SonucPage({
</main> </main>
} }
> >
{searchParams.then((params) => { {Promise.all([searchParams, cookies()]).then(([params, cerezler]) => {
const sira = Number.parseInt(params.sira ?? "", 10); // Profil (sıra/tür/il/tip) normalde PROFIL_CEREZ_ADI çerezinden gelir;
// URL param taşımaz (bkz. sonucHref). Query'de sira varsa eski
// paylaşılan/yer imli link demektir — açık URL niyeti çerezi ezer,
// eski linkler çalışmayı sürdürür.
const eskiLinkSira = Number.parseInt(params.sira ?? "", 10);
const eskiLink = Number.isFinite(eskiLinkSira);
const profil = eskiLink
? null
: cerezdenTercihProfili(cerezler.get(PROFIL_CEREZ_ADI)?.value);
const sira = eskiLink ? eskiLinkSira : (profil?.sira ?? Number.NaN);
const turHam = eskiLink ? params.tur : profil?.tur;
const turKey: PuanTuruKey = const turKey: PuanTuruKey =
params.tur && params.tur in PUAN_TURLERI turHam && turHam in PUAN_TURLERI ? (turHam as PuanTuruKey) : "say";
? (params.tur as PuanTuruKey) // Tablo filtreleri: il en fazla 5 tekrarsız, tip devlet|vakif
: "say"; // allowlist'li (çerez tarafı tercihProfiliDogrula'dan zaten geçmiş
// Sihirbaz seçimlerinden gelen tablo filtreleri (bkz. sonucHref): // olsa da iki kaynak aynı süzgeçten akar).
// il tekrarlı param, tip devlet|vakif allowlist'li const hamIller = eskiLink
? Array.isArray(params.il)
? params.il
: params.il
? [params.il]
: []
: (profil?.secimler.iller ?? []);
const iller = [ const iller = [
...new Set( ...new Set(hamIller.filter((i) => i.trim().length > 0)),
(Array.isArray(params.il)
? params.il
: params.il
? [params.il]
: []
).filter((i) => i.trim().length > 0),
),
].slice(0, 5); ].slice(0, 5);
const tipHam = eskiLink ? params.tip : profil?.secimler.universiteTipi;
const uniturGrubu = const uniturGrubu =
params.tip === "devlet" || params.tip === "vakif" tipHam === "devlet" || tipHam === "vakif" ? tipHam : undefined;
? params.tip
: undefined;
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
return ( return (
@@ -90,7 +104,7 @@ export default function SonucPage({
{/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında, {/* Yapay Zeka'dan bağımsız ham tablo — haritanın hemen altında,
sayfanın ana gövdesi; puan türü navbar'daki sıralama sayfanın ana gövdesi; puan türü navbar'daki sıralama
rozetinden değiştirilir. Sihirbaz seçimleri (il, devlet/ rozetinden değiştirilir. Sihirbaz seçimleri (il, devlet/
vakıf) URL paramlarıyla tabloya uygulanır. */} vakıf) profil çerezinden tabloya uygulanır. */}
<SonucProgramTablosu <SonucProgramTablosu
sira={sira} sira={sira}
tur={turKey} tur={turKey}

View File

@@ -82,14 +82,16 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
function sihirbazaGit() { function sihirbazaGit() {
onSec(); onSec();
// Orkestratör (SihirbazBolumu) yalnızca /sonuc?sira= geçerliyken monte; // Orkestratör (SihirbazBolumu) yalnızca /sonuc tabloyu render ederken
// sırasız /sonuc'ta (SiraGerekli görünümü) olay boşa gider — kapıya düş. // monte: profil çerezi (client'ta aynası localStorage profili) ya da eski
if (pathname === "/sonuc" && searchParams.get("sira")) { // linklerdeki ?sira= geçerliyken. Profilsiz /sonuc'ta (SiraGerekli
// görünümü) olay boşa gider — kapıya düş.
if (pathname === "/sonuc" && (profil || searchParams.get("sira"))) {
window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT)); window.dispatchEvent(new Event(SIHIRBAZ_AC_EVENT));
return; return;
} }
if (profil) { if (profil) {
router.push(sonucHref(profil, { sihirbaz: "1" })); router.push(sonucHref({ sihirbaz: "1" }));
return; return;
} }
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam // Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam

View File

@@ -15,6 +15,7 @@ import { SiteFooter } from "@/components/site-footer";
import { Sayfalama, SAYFA_BOYU } from "./sayfalama"; import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
import { PagePixelDivider } from "@/components/pixel-decor"; import { PagePixelDivider } from "@/components/pixel-decor";
import { UniversiteKonumHaritasi } from "./universite-konum-haritasi"; import { UniversiteKonumHaritasi } from "./universite-konum-haritasi";
import { uniUlkeKonum } from "@/lib/harita-ulkeler";
import { UniversiteProgramTablosu } from "./universite-program-tablosu"; import { UniversiteProgramTablosu } from "./universite-program-tablosu";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
import { uniLogoYolu } from "@/lib/uni-logolar"; import { uniLogoYolu } from "@/lib/uni-logolar";
@@ -94,6 +95,7 @@ export function UniversiteIcerik({
const buYol = ilkSayfa const buYol = ilkSayfa
? `/universite/${slug}` ? `/universite/${slug}`
: `/universite/${slug}/sayfa/${sayfa}`; : `/universite/${slug}/sayfa/${sayfa}`;
const yurtdisi = uniUlkeKonum(uni.ad, uni.il);
return ( return (
<> <>
@@ -116,12 +118,16 @@ export function UniversiteIcerik({
...(uniLogoYolu(slug) ...(uniLogoYolu(slug)
? { logo: `${SITE_URL}${uniLogoYolu(slug)}` } ? { logo: `${SITE_URL}${uniLogoYolu(slug)}` }
: {}), : {}),
...(uni.il ...(uni.il || yurtdisi
? { ? {
address: { address: {
"@type": "PostalAddress", "@type": "PostalAddress",
addressLocality: trBaslikDuzeni(uni.il), ...(yurtdisi?.sehir
addressCountry: "TR", ? { addressLocality: yurtdisi.sehir }
: uni.il
? { addressLocality: trBaslikDuzeni(uni.il) }
: {}),
addressCountry: yurtdisi ? yurtdisi.iso : "TR",
}, },
} }
: {}), : {}),

View File

@@ -5,6 +5,15 @@ import {
HARITA_YUKSEKLIK, HARITA_YUKSEKLIK,
normalizeIlAdi, normalizeIlAdi,
} from "@/lib/harita"; } from "@/lib/harita";
import {
ULKE_HARITA_GENISLIK,
ULKE_HARITA_YUKSEKLIK,
uniUlkeKonum,
type UlkeKonum,
} from "@/lib/harita-ulkeler";
const SECILI_DOLGU = "oklch(0.809 0.105 251.8)";
const ZEMIN_DOLGU = "oklch(0.929 0.013 255.5)";
export function UniversiteKonumHaritasi({ export function UniversiteKonumHaritasi({
universite, universite,
@@ -13,8 +22,55 @@ export function UniversiteKonumHaritasi({
universite: string; universite: string;
il: string | null; il: string | null;
}) { }) {
const yurtdisi = uniUlkeKonum(universite, il);
if (yurtdisi) {
return (
<KonumBolumu universite={universite}>
<UlkeHaritasi universite={universite} konum={yurtdisi} />
<p className="pointer-events-none absolute bottom-0 left-0 text-[11px] font-medium text-slate-500">
{yurtdisi.ulkeAd}
{yurtdisi.sehir ? ` · ${yurtdisi.sehir}` : ""}
</p>
</KonumBolumu>
);
}
if (!il) return null; if (!il) return null;
return (
<KonumBolumu universite={universite}>
<svg
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
>
{cities.map((city) => {
const secili = il ? normalizeIlAdi(city.name) === il : false;
return (
<path
key={city.id}
d={city.path}
fill={secili ? SECILI_DOLGU : ZEMIN_DOLGU}
stroke="white"
strokeWidth={secili ? 1.8 : 1}
>
<title>{city.name}</title>
</path>
);
})}
</svg>
</KonumBolumu>
);
}
function KonumBolumu({
universite,
children,
}: {
universite: string;
children: React.ReactNode;
}) {
return ( return (
<section <section
aria-labelledby="universite-konumu" aria-labelledby="universite-konumu"
@@ -23,34 +79,34 @@ export function UniversiteKonumHaritasi({
<h2 id="universite-konumu" className="sr-only"> <h2 id="universite-konumu" className="sr-only">
{universite} kampüs konumu {universite} kampüs konumu
</h2> </h2>
<div className="relative h-full min-h-44 w-full">{children}</div>
<div className="relative h-full min-h-44 w-full">
<svg
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
>
{cities.map((city) => {
const secili = il ? normalizeIlAdi(city.name) === il : false;
return (
<path
key={city.id}
d={city.path}
fill={
secili
? "oklch(0.809 0.105 251.8)"
: "oklch(0.929 0.013 255.5)"
}
stroke="white"
strokeWidth={secili ? 1.8 : 1}
>
<title>{city.name}</title>
</path>
);
})}
</svg>
</div>
</section> </section>
); );
} }
function UlkeHaritasi({
universite,
konum,
}: {
universite: string;
konum: UlkeKonum;
}) {
const { harita, ulkeAd, sehir } = konum;
return (
<svg
viewBox={`0 0 ${ULKE_HARITA_GENISLIK} ${ULKE_HARITA_YUKSEKLIK}`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`${universite} konumunun ${ulkeAd} haritasındaki görünümü${sehir ? ` (${sehir})` : ""}`}
>
{harita.baglam.map((d, i) => (
<path key={`b${i}`} d={d} fill={ZEMIN_DOLGU} stroke="white" strokeWidth={1} />
))}
{harita.ulke.map((d, i) => (
<path key={`u${i}`} d={d} fill={SECILI_DOLGU} stroke="white" strokeWidth={1.8}>
<title>{ulkeAd}</title>
</path>
))}
</svg>
);
}

View File

@@ -106,8 +106,8 @@ export function ProgramTablosu({
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() => const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
varsayilanDilim(sonuclar), varsayilanDilim(sonuclar),
); );
// Sihirbaz seçimlerinden URL'e yazılan filtreler (bkz. sonucHref) — // Sihirbaz seçimlerinden profil çerezine yazılıp SSR'da prop olarak gelen
// sayfalama isteklerinde ve veri anahtarında kullanılır. // filtreler — sayfalama isteklerinde ve veri anahtarında kullanılır.
const iller = useMemo(() => filtreIller ?? [], [filtreIller]); const iller = useMemo(() => filtreIller ?? [], [filtreIller]);
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür/filtre // "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür/filtre

View File

@@ -81,7 +81,7 @@ export function SecimlerimPaneli() {
asChild asChild
className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600" className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
> >
<Link href={sonucHref(profil)}>Sonuçlara git</Link> <Link href={sonucHref()}>Sonuçlara git</Link>
</Button> </Button>
) : ( ) : (
<Button <Button

View File

@@ -93,7 +93,7 @@ function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi {
} }
/** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */ /** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */
function cakismaAc(pinler: HaritaPin[]): HaritaPin[] { function cakismaAc(pinler: HaritaPin[], halka: number): HaritaPin[] {
const gruplar = new Map<string, HaritaPin[]>(); const gruplar = new Map<string, HaritaPin[]>();
for (const p of pinler) { for (const p of pinler) {
const key = `${Math.round(p.x)}:${Math.round(p.y)}`; const key = `${Math.round(p.x)}:${Math.round(p.y)}`;
@@ -109,7 +109,11 @@ function cakismaAc(pinler: HaritaPin[]): HaritaPin[] {
} }
grup.forEach((p, i) => { grup.forEach((p, i) => {
const aci = (2 * Math.PI * i) / grup.length; const aci = (2 * Math.PI * i) / grup.length;
sonuc.push({ ...p, x: p.x + Math.cos(aci) * 5, y: p.y + Math.sin(aci) * 5 }); sonuc.push({
...p,
x: p.x + Math.cos(aci) * halka,
y: p.y + Math.sin(aci) * halka,
});
}); });
} }
return sonuc; return sonuc;
@@ -243,7 +247,12 @@ export function TercihHaritasi({
() => (seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []), () => (seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []),
[ilPinleri, seciliIl], [ilPinleri, seciliIl],
); );
const acikPinler = useMemo(() => cakismaAc(seciliPinler), [seciliPinler]); // Halka yarıçapı zoom oranıyla ölçeklenir ki pinler ekranda hep aynı
// mesafede ayrışsın (pin boyutları da sf ile ekran-sabit).
const acikPinler = useMemo(
() => cakismaAc(seciliPinler, 20 * sf),
[seciliPinler, sf],
);
// Ülke seviyesi toplu pinler: il başına adet/merkez/renk tek geçişte // Ülke seviyesi toplu pinler: il başına adet/merkez/renk tek geçişte
// (üç ayrı reduce + baskinRisk dördüncü geçişti) ve memo'da. // (üç ayrı reduce + baskinRisk dördüncü geçişti) ve memo'da.
@@ -370,7 +379,7 @@ export function TercihHaritasi({
// Kilitli pinlerde logo da gösterilmez — üniversite kimliği // Kilitli pinlerde logo da gösterilmez — üniversite kimliği
// client'a inmediği gibi görselden de sızmamalı. // client'a inmediği gibi görselden de sızmamalı.
const logo = p.kilitli ? null : uniLogoYoluFromAd(p.universite); const logo = p.kilitli ? null : uniLogoYoluFromAd(p.universite);
const r = (p.kilitli ? 7 : logo ? 11 : 8) * sf; const r = (p.kilitli ? 12 : logo ? 20 : 14) * sf;
const etiket = p.kilitli ? null : kisaUniAdi(p.universite); const etiket = p.kilitli ? null : kisaUniAdi(p.universite);
return ( return (
<g <g
@@ -396,31 +405,31 @@ export function TercihHaritasi({
r={r} r={r}
fill="white" fill="white"
stroke={RISK_RENK[p.risk]} stroke={RISK_RENK[p.risk]}
strokeWidth={2 * sf} strokeWidth={2.5 * sf}
/> />
<image <image
href={logo} href={logo}
x={p.x - 8 * sf} x={p.x - 15 * sf}
y={p.y - 8 * sf} y={p.y - 15 * sf}
width={16 * sf} width={30 * sf}
height={16 * sf} height={30 * sf}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
className="pointer-events-none" className="pointer-events-none"
/> />
<circle <circle
cx={p.x + r * 0.78} cx={p.x + r * 0.78}
cy={p.y - r * 0.78} cy={p.y - r * 0.78}
r={4.5 * sf} r={7.5 * sf}
fill={RISK_RENK[p.risk]} fill={RISK_RENK[p.risk]}
stroke="white" stroke="white"
strokeWidth={1.2 * sf} strokeWidth={1.5 * sf}
/> />
<text <text
x={p.x + r * 0.78} x={p.x + r * 0.78}
y={p.y - r * 0.78} y={p.y - r * 0.78}
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize={6 * sf} fontSize={9.5 * sf}
fontWeight={700} fontWeight={700}
fill="white" fill="white"
className="pointer-events-none select-none" className="pointer-events-none select-none"
@@ -437,14 +446,14 @@ export function TercihHaritasi({
fill={p.kilitli ? "oklch(0.704 0.04 256.8)" : RISK_RENK[p.risk]} fill={p.kilitli ? "oklch(0.704 0.04 256.8)" : RISK_RENK[p.risk]}
stroke="white" stroke="white"
strokeWidth={2 * sf} strokeWidth={2 * sf}
strokeDasharray={p.kilitli ? `${3 * sf} ${2.5 * sf}` : undefined} strokeDasharray={p.kilitli ? `${4 * sf} ${3 * sf}` : undefined}
/> />
<text <text
x={p.x} x={p.x}
y={p.y} y={p.y}
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize={9 * sf} fontSize={13 * sf}
fontWeight={700} fontWeight={700}
fill="white" fill="white"
className="pointer-events-none select-none" className="pointer-events-none select-none"
@@ -456,13 +465,13 @@ export function TercihHaritasi({
{etiket ? ( {etiket ? (
<text <text
x={p.x} x={p.x}
y={p.y - r - 4 * sf} y={p.y - r - 6 * sf}
textAnchor="middle" textAnchor="middle"
fontSize={10.5 * sf} fontSize={16 * sf}
fontWeight={600} fontWeight={600}
fill="oklch(0.279 0.041 260.0)" // slate-800 fill="oklch(0.279 0.041 260.0)" // slate-800
stroke="white" stroke="white"
strokeWidth={3 * sf} strokeWidth={4 * sf}
paintOrder="stroke" paintOrder="stroke"
className="pointer-events-none select-none" className="pointer-events-none select-none"
> >

View File

@@ -199,11 +199,14 @@ function KapıIcerik({
if (!duzenle && !bekleyenVar && hedef !== "sihirbaz") { if (!duzenle && !bekleyenVar && hedef !== "sihirbaz") {
toast.success("Tercihlerin kaydedildi."); toast.success("Tercihlerin kaydedildi.");
} }
// Düzenlemede replace: aynı /sonuc sayfasında sira/tur değişince geçmiş // Düzenlemede replace (geçmiş şişmesin), ilk kurulumda push. Profil
// şişmesin; ilk kurulumda push ile hedefe gitsin. // artık çerezden SSR'landığı ve URL çoğu zaman değişmediği için sunucu
// tablosunun yeni profille tazelenmesi her iki yolda da refresh ile
// garanti edilir (zaten /sonuc'tayken aynı URL'e push no-op kalabilir).
if (yonlendir) { if (yonlendir) {
if (duzenle) router.replace(yonlendir); if (duzenle) router.replace(yonlendir);
else router.push(yonlendir); else router.push(yonlendir);
router.refresh();
} }
} }

View File

@@ -185,7 +185,7 @@ export function sonucaGitIste(secenekler?: { dogrudanSira?: boolean }): {
if (!durum.profilYuklendi) yukle(); if (!durum.profilYuklendi) yukle();
const profil = durum.profil ?? tercihProfiliOku(); const profil = durum.profil ?? tercihProfiliOku();
if (profil) { if (profil) {
return { href: sonucHref(profil), kapiAcildi: false }; return { href: sonucHref(), kapiAcildi: false };
} }
profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false); profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false);
return { href: null, kapiAcildi: true }; return { href: null, kapiAcildi: true };
@@ -245,7 +245,6 @@ export function profilTamamlandi(
profil: TercihProfili, profil: TercihProfili,
girisli = false, girisli = false,
): string | null { ): string | null {
const onceki = durum.profil;
const duzenle = durum.duzenle; const duzenle = durum.duzenle;
riskleriYenile(profil); riskleriYenile(profil);
const bekleyen = durum.bekleyen; const bekleyen = durum.bekleyen;
@@ -268,20 +267,14 @@ export function profilTamamlandi(
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta // Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1), // akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar. // girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
return sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" }); return sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" });
}
if (hedef === "sonuc") return sonucHref(profil);
// Düzenlemede sıra/tür değişince /sonuc?sira= URL'si yenilenmezse sunucudaki
// hayal/dengeli/garanti tablosu eski sıralamada kalır (profil localStorage'da
// güncellenir ama sayfa searchParams'tan hesaplanır).
if (
duzenle &&
typeof window !== "undefined" &&
window.location.pathname.startsWith("/sonuc") &&
(onceki?.sira !== profil.sira || onceki?.tur !== profil.tur)
) {
return sonucHref(profil);
} }
if (hedef === "sonuc") return sonucHref();
// Düzenleme /sonuc'a götürür: tablo profil çerezinden SSR'lanır
// (sira/tur/il/tip) ve tercihProfiliYaz çerezi az önce güncelledi. URL
// değişmediği için sunucu tablosunun tazelenmesi kapı bileşenindeki
// router.refresh ile garanti edilir.
if (duzenle) return sonucHref();
return null; return null;
} }

View File

@@ -21,7 +21,7 @@ export function RaporYokCta() {
asChild asChild
className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="h-11 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}> <Link href={profil ? sonucHref({ sihirbaz: "1" }) : "/"}>
24&apos;lük listeni oluştur 24&apos;lük listeni oluştur
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Link> </Link>

View File

@@ -49,7 +49,7 @@ export function KapanisCta() {
size="lg" size="lg"
className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" className="mt-8 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
> >
<Link href={sonucHref(profil)}> <Link href={sonucHref()}>
Üniversitelere göz at Üniversitelere göz at
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Link> </Link>

View File

@@ -240,7 +240,7 @@ export function DemoKapanisCta() {
: "Kuralları öğrendin — şimdi kendi sıralamanla dene."} : "Kuralları öğrendin — şimdi kendi sıralamanla dene."}
</p> </p>
<Link <Link
href={profil ? sonucHref(profil) : "/"} href={profil ? sonucHref() : "/"}
className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600" className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-colors duration-200 hover:bg-orange-600"
> >
{profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"} {profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"}

View File

@@ -151,7 +151,7 @@ export function ListeUretici({
{profil ? ( {profil ? (
<p className="mt-4 text-xs text-slate-500"> <p className="mt-4 text-xs text-slate-500">
Seçimlerin kayıtlı {" "} Seçimlerin kayıtlı {" "}
<Link href={sonucHref(profil)} className="underline"> <Link href={sonucHref()} className="underline">
sonuç sayfasına dönebilirsin sonuç sayfasına dönebilirsin
</Link> </Link>
. .

View File

@@ -28,7 +28,7 @@ export function ListemBosCta() {
size="lg" size="lg"
className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600" className="mt-6 cursor-pointer bg-orange-500 text-white hover:bg-orange-600"
> >
<Link href={profil ? sonucHref(profil, { sihirbaz: "1" }) : "/"}> <Link href={profil ? sonucHref({ sihirbaz: "1" }) : "/"}>
{profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"} {profil ? "Yapay Zeka listeni kur" : "Sıralamanı gir, Yapay Zeka listeni kur"}
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Link> </Link>

View File

@@ -108,7 +108,7 @@ export function TadimlikSatiri({
const seri = raporSiraSerisi(p.siraGecmisi); const seri = raporSiraSerisi(p.siraGecmisi);
const taban = p.efektifSira ?? programEtkinSira(seri); const taban = p.efektifSira ?? programEtkinSira(seri);
const devlet = p.unitur === "DEVLET"; const devlet = p.unitur === "DEVLET";
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`; const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref())}`;
return ( return (
<section ref={gorunumRef} className="mt-16 min-w-0"> <section ref={gorunumRef} className="mt-16 min-w-0">

View File

@@ -123,9 +123,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
const yeniProfil = { sira, tur, secimler }; const yeniProfil = { sira, tur, secimler };
tercihProfiliYaz(yeniProfil); tercihProfiliYaz(yeniProfil);
setAcik(false); setAcik(false);
router.push( router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" }));
sonucHref(yeniProfil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
);
} }
function acikDegisti(v: boolean) { function acikDegisti(v: boolean) {
@@ -150,7 +148,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
programların hazır. programların hazır.
</p> </p>
<Link <Link
href={sonucHref(profil)} href={sonucHref()}
className="mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-orange-500 px-7 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600" className="mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-full bg-orange-500 px-7 text-sm font-semibold text-white transition-colors duration-200 hover:bg-orange-600"
> >
Sıralamana uygun programları gör Sıralamana uygun programları gör

View File

@@ -100,9 +100,7 @@ export function HeroForm() {
const profil = { sira, tur, secimler }; const profil = { sira, tur, secimler };
tercihProfiliYaz(profil); tercihProfiliYaz(profil);
setModalAcik(false); setModalAcik(false);
router.push( router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" }));
sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
);
} }
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür. // Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
@@ -116,7 +114,7 @@ export function HeroForm() {
size="lg" size="lg"
className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto" className="w-full cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 sm:w-auto"
> >
<Link href={sonucHref(kayitliProfil)}> <Link href={sonucHref()}>
Üniversitelere göz at Üniversitelere göz at
<ArrowRight className="size-4" aria-hidden /> <ArrowRight className="size-4" aria-hidden />
</Link> </Link>

View File

@@ -110,6 +110,9 @@ export function SihirbazAdimlar({
// eşleşme için iki taraf da normalizeIlAdi'dan geçirilir. // eşleşme için iki taraf da normalizeIlAdi'dan geçirilir.
const [ilArama, setIlArama] = useState(""); const [ilArama, setIlArama] = useState("");
const ilAramaNorm = normalizeIlAdi(ilArama.trim()); const ilAramaNorm = normalizeIlAdi(ilArama.trim());
// 81 çip ilk bakışta boğucu; varsayılan görünüm ilk 10 il + "+N il daha"
// rozeti. Arama açıkken kırpma yok (arama zaten görünümü daraltıyor).
const [tumIllerAcik, setTumIllerAcik] = useState(false);
const gorunenTurkiyeIlleri = useMemo( const gorunenTurkiyeIlleri = useMemo(
() => () =>
ilAramaNorm ilAramaNorm
@@ -119,6 +122,14 @@ export function SihirbazAdimlar({
: turkiyeIlleri, : turkiyeIlleri,
[turkiyeIlleri, ilAramaNorm], [turkiyeIlleri, ilAramaNorm],
); );
const IL_ON_IZLEME = 10;
const ilListesiKirpik =
!ilAramaNorm &&
!tumIllerAcik &&
gorunenTurkiyeIlleri.length > IL_ON_IZLEME;
const listelenenTurkiyeIlleri = ilListesiKirpik
? gorunenTurkiyeIlleri.slice(0, IL_ON_IZLEME)
: gorunenTurkiyeIlleri;
const gorunenDisIller = useMemo( const gorunenDisIller = useMemo(
() => () =>
ilAramaNorm ilAramaNorm
@@ -314,15 +325,16 @@ export function SihirbazAdimlar({
{/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde {/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde
güvenilir dokunma hedefi değil); iller seçili kategorilere göre güvenilir dokunma hedefi değil); iller seçili kategorilere göre
süzülmüş, program sayısına göre sıralı listelenir. Penceresinde süzülmüş, program sayısına göre sıralı listelenir. Varsayılan
programı olan TÜM iller görünür (kırpma yok); arama yalnızca görünüm ilk 10 il; kalanı "+N il daha" rozetiyle açılır. Arama
görünümü daraltır, seçimleri etkilemez. */} yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
devre dışı bırakır. */}
<div <div
className={`mt-3 flex max-h-56 flex-wrap gap-1.5 overflow-y-auto sm:max-h-none sm:overflow-visible ${ className={`mt-3 flex max-h-56 flex-wrap gap-1.5 overflow-y-auto sm:max-h-none sm:overflow-visible ${
facetYukleniyor ? "animate-pulse opacity-60" : "" facetYukleniyor ? "animate-pulse opacity-60" : ""
}`} }`}
> >
{gorunenTurkiyeIlleri.map((i) => ( {listelenenTurkiyeIlleri.map((i) => (
<Cip <Cip
key={i.il} key={i.il}
kucuk kucuk
@@ -333,6 +345,27 @@ export function SihirbazAdimlar({
<span className="ml-1 text-[10px] opacity-70">{i.adet}</span> <span className="ml-1 text-[10px] opacity-70">{i.adet}</span>
</Cip> </Cip>
))} ))}
{ilListesiKirpik ? (
<button
type="button"
onClick={() => setTumIllerAcik(true)}
className="cursor-pointer rounded-full border border-dashed border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors duration-200 hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700"
>
+{gorunenTurkiyeIlleri.length - IL_ON_IZLEME} il daha
</button>
) : null}
{!ilListesiKirpik &&
tumIllerAcik &&
!ilAramaNorm &&
gorunenTurkiyeIlleri.length > IL_ON_IZLEME ? (
<button
type="button"
onClick={() => setTumIllerAcik(false)}
className="cursor-pointer rounded-full border border-dashed border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-500 transition-colors duration-200 hover:border-slate-400 hover:bg-slate-50 hover:text-slate-700"
>
Daha az göster
</button>
) : null}
</div> </div>
{ilAramaNorm && {ilAramaNorm &&

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç. // Profil çerezi yokken: localStorage'da kayıtlı profil varsa çereze taşıyıp
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak. // sayfayı tazele (URL değişmez); yoksa kapıyı aç. Sıra bilgisi localStorage'daki
// TercihProfili ile tüm uygulamada ortak, sunucu tablosu çerezden okur.
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -9,7 +10,7 @@ import Link from "next/link";
import { ArrowLeft, Trophy } from "lucide-react"; import { ArrowLeft, Trophy } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PagePixelDivider } from "@/components/pixel-decor"; import { PagePixelDivider } from "@/components/pixel-decor";
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler"; import { profilCerezineYaz } from "@/features/sihirbaz/sihirbaz-profil";
import { import {
sonucaGitIste, sonucaGitIste,
useProfilKapisi, useProfilKapisi,
@@ -20,9 +21,14 @@ export function SiraGerekli() {
const { profil, profilYuklendi } = useProfilKapisi(); const { profil, profilYuklendi } = useProfilKapisi();
// "Yönleniyor" durumu ayrı state gerektirmez: profil varken aşağıdaki koşul // "Yönleniyor" durumu ayrı state gerektirmez: profil varken aşağıdaki koşul
// zaten bekleme ekranını gösteriyor; effect yalnızca yönlendirmeyi yapar. // zaten bekleme ekranını gösteriyor. Çerezi olmayan eski kullanıcı (profil
// yalnızca localStorage'da) burada çereze taşınır; refresh sunucu tablosunu
// aynı URL'de yeni çerezle render eder.
useEffect(() => { useEffect(() => {
if (profilYuklendi && profil) router.replace(sonucHref(profil)); if (profilYuklendi && profil) {
profilCerezineYaz(profil);
router.refresh();
}
}, [profil, profilYuklendi, router]); }, [profil, profilYuklendi, router]);
if (!profilYuklendi || profil) { if (!profilYuklendi || profil) {

View File

@@ -2,6 +2,7 @@
// Şema, doğrulama ve sabitler sihirbaz-sabitler.ts'te yaşar. // Şema, doğrulama ve sabitler sihirbaz-sabitler.ts'te yaşar.
import { import {
PROFIL_CEREZ_ADI,
PROFIL_DEGISTI_EVENT, PROFIL_DEGISTI_EVENT,
SIHIRBAZ_STORAGE_KEY, SIHIRBAZ_STORAGE_KEY,
tercihProfiliDogrula, tercihProfiliDogrula,
@@ -42,6 +43,24 @@ export function tercihProfiliOku(): TercihProfili | null {
return profilCache; return profilCache;
} }
/**
* Profilin sunucu aynası: /sonuc SSR'ı tabloyu bu çerezden filtreler
* (bkz. PROFIL_CEREZ_ADI). localStorage'ı olup çerezi olmayan eski
* kullanıcıların taşınması için SiraGerekli de bunu çağırır.
*/
export function profilCerezineYaz(profil: TercihProfili): void {
if (typeof document === "undefined") return;
const guvenli = window.location.protocol === "https:" ? "; secure" : "";
document.cookie = `${PROFIL_CEREZ_ADI}=${encodeURIComponent(
JSON.stringify(profil),
)}; path=/; max-age=31536000; samesite=lax${guvenli}`;
}
function profilCereziniSil(): void {
if (typeof document === "undefined") return;
document.cookie = `${PROFIL_CEREZ_ADI}=; path=/; max-age=0`;
}
/** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */ /** Ortak profili yazar; hero ve Yapay Zeka sihirbazıyla aynı anahtarı kullanır. */
export function tercihProfiliYaz(profil: TercihProfili): void { export function tercihProfiliYaz(profil: TercihProfili): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -50,6 +69,7 @@ export function tercihProfiliYaz(profil: TercihProfili): void {
try { try {
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru)); localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
} catch {} } catch {}
profilCerezineYaz(dogru);
profilCache = dogru; profilCache = dogru;
profilDegistiginiYayinla(); profilDegistiginiYayinla();
} }
@@ -60,6 +80,7 @@ export function tercihProfiliSil(): void {
try { try {
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY); localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
} catch {} } catch {}
profilCereziniSil();
profilCache = null; profilCache = null;
profilDegistiginiYayinla(); profilDegistiginiYayinla();
} }

View File

@@ -37,6 +37,12 @@ export const PUAN_TURU_ETIKET: Record<string, string> = {
// dönene kadar burada bekler. // dönene kadar burada bekler.
export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz"; export const SIHIRBAZ_STORAGE_KEY = "kolaytercih.sihirbaz";
// Profilin sunucuya giden kopyası: /sonuc tablosu SSR'da bu çerezden
// filtrelenir, URL'de sira/il/tip paramı taşınmaz. localStorage client
// tarafındaki tek doğruluk kaynağı olmayı sürdürür; çerez onun aynasıdır
// (tercihProfiliYaz/Sil ikisini birlikte günceller).
export const PROFIL_CEREZ_ADI = "kolaytercih.profil";
// storage olayı yalnızca diğer sekmelerde tetiklenir; aynı sekmedeki // storage olayı yalnızca diğer sekmelerde tetiklenir; aynı sekmedeki
// aboneler (ör. navbar sıralama rozeti) bu olayla haberdar edilir. // aboneler (ör. navbar sıralama rozeti) bu olayla haberdar edilir.
export const PROFIL_DEGISTI_EVENT = "kolaytercih:profil-degisti"; export const PROFIL_DEGISTI_EVENT = "kolaytercih:profil-degisti";
@@ -126,30 +132,27 @@ export function tercihProfiliDogrula(girdi: unknown): TercihProfili | null {
return { sira, tur: p.tur, secimler }; return { sira, tur: p.tur, secimler };
} }
/** /** /sonuc çerezindeki ham değeri güvenle profile çözer (SSR tarafı). */
* Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu export function cerezdenTercihProfili(
* kullanır. Profilde sihirbaz seçimleri varsa il + üniversite tipi de URL'e ham: string | undefined | null,
* yazılır: /sonuc'un ham tablosu SSR'da bu paramlarla filtrelenir (tek ): TercihProfili | null {
* doğruluk kaynağı URL; localStorage profili yalnızca URL'i üretir). if (!ham) return null;
*/ try {
export function sonucHref( return tercihProfiliDogrula(JSON.parse(decodeURIComponent(ham)));
profil: Pick<TercihProfili, "sira" | "tur"> & { } catch {
secimler?: SihirbazSecimleri; return null;
},
ekstra?: Record<string, string>,
): string {
const q = new URLSearchParams({
sira: String(profil.sira),
tur: profil.tur,
});
if (profil.secimler) {
for (const il of profil.secimler.iller) q.append("il", il);
if (profil.secimler.universiteTipi !== "farketmez") {
q.set("tip", profil.secimler.universiteTipi);
}
} }
for (const [k, v] of Object.entries(ekstra ?? {})) q.set(k, v); }
return `/sonuc?${q}`;
/**
* Sonuç sayfası URL'i. Profil verisi (sıra/tür/il/tip) URL'de taşınmaz —
* /sonuc'un ham tablosu SSR'da PROFIL_CEREZ_ADI çerezinden filtrelenir.
* `ekstra` yalnızca tek seferlik akış bayrakları içindir (sihirbaz=1, hazir=1).
*/
export function sonucHref(ekstra?: Record<string, string>): string {
const girdiler = Object.entries(ekstra ?? {});
if (girdiler.length === 0) return "/sonuc";
return `/sonuc?${new URLSearchParams(girdiler)}`;
} }
/** Prompt'a enjekte edilecek Türkçe özet. */ /** Prompt'a enjekte edilecek Türkçe özet. */

File diff suppressed because one or more lines are too long

115
src/lib/harita-ulkeler.ts Normal file
View File

@@ -0,0 +1,115 @@
// Türkiye dışındaki (KKTC ve yurtdışı) üniversitelerin ülke haritası çözümü.
// Kontür verisi harita-ulkeler-verisi.ts'te (scripts/ulke-harita-uret.ts üretir).
import {
ULKE_HARITALARI,
type UlkeKodu,
type UlkeHarita,
} from "./harita-ulkeler-verisi";
export {
ULKE_HARITALARI,
ULKE_HARITA_GENISLIK,
ULKE_HARITA_YUKSEKLIK,
} from "./harita-ulkeler-verisi";
export type { UlkeKodu, UlkeHarita } from "./harita-ulkeler-verisi";
/** Schema.org addressCountry için ISO 3166-1 alpha-2 kodları. */
export const ULKE_ISO: Record<UlkeKodu, string> = {
kktc: "CY",
azerbaycan: "AZ",
kirgizistan: "KG",
kazakistan: "KZ",
"bosna-hersek": "BA",
makedonya: "MK",
arnavutluk: "AL",
};
type Sehir = { ulke: UlkeKodu; ad: string };
/** DB'deki il değerlerinden ve düzeltme tablosundan şehir anahtarları. */
const SEHIRLER: Record<string, Sehir> = {
GİRNE: { ulke: "kktc", ad: "Girne" },
LEFKOŞA: { ulke: "kktc", ad: "Lefkoşa" },
GAZİMAĞUSA: { ulke: "kktc", ad: "Gazimağusa" },
GÜZELYURT: { ulke: "kktc", ad: "Güzelyurt" },
LEFKE: { ulke: "kktc", ad: "Lefke" },
"BAKÜ-AZERBAYCAN": { ulke: "azerbaycan", ad: "Bakü" },
"BİŞKEK-KIRGIZİSTAN": { ulke: "kirgizistan", ad: "Bişkek" },
"TÜRKİSTAN-KAZAKİSTAN": { ulke: "kazakistan", ad: "Türkistan" },
"SARAYBOSNA - BOSNA - HERSEK": { ulke: "bosna-hersek", ad: "Saraybosna" },
ÜSKÜP: { ulke: "makedonya", ad: "Üsküp" },
TİRAN: { ulke: "arnavutluk", ad: "Tiran" },
};
/**
* Kaynak verideki gürültü için üniversite adına göre düzeltme:
* - Uluslararası Balkan Üniversitesi Üsküp'tedir; YÖK Atlas satırlarının
* çoğu il'i yanlışlıkla "SARAYBOSNA - BOSNA - HERSEK" der.
* - Tiran New York Üniversitesi'nin il alanı boştur.
* - ODTÜ ve ASBÜ'nün KKTC kampüs satırları vardır ama ana kampüs Türkiye'de;
* null → Türkiye haritasında kalır.
* Anahtar: uniAdiNormalize edilmiş adın tr-TR büyük harf hali.
*/
const UNI_DUZELTME: Record<string, keyof typeof SEHIRLER | null> = {
"ULUSLARARASI BALKAN ÜNİVERSİTESİ": "ÜSKÜP",
"TİRAN NEW YORK ÜNİVERSİTESİ": "TİRAN",
"KIBRIS AYDIN ÜNİVERSİTESİ": "GİRNE", // il alanı yalnızca "KIBRIS"
"ORTA DOĞU TEKNİK ÜNİVERSİTESİ": null,
"ANKARA SOSYAL BİLİMLER ÜNİVERSİTESİ": null,
};
/** il değeri Türkiye dışını mı gösteriyor? ("KIBRIS" genel değeri dahil) */
const YURTDISI_IL = new Set([...Object.keys(SEHIRLER), "KIBRIS"]);
export type UlkeKonum = {
ulke: UlkeKodu;
ulkeAd: string;
iso: string;
harita: UlkeHarita;
/** Şehir adı; genel "KIBRIS" gibi şehri bilinmeyen durumda null */
sehir: string | null;
};
function sehirdenKonum(anahtar: keyof typeof SEHIRLER): UlkeKonum {
const sehir = SEHIRLER[anahtar];
const harita = ULKE_HARITALARI[sehir.ulke];
return {
ulke: sehir.ulke,
ulkeAd: harita.ad,
iso: ULKE_ISO[sehir.ulke],
harita,
sehir: sehir.ad,
};
}
/**
* Üniversite Türkiye dışındaysa ülke haritası + şehir konumu döner;
* Türkiye'deyse (veya çözülemiyorsa) null — Türkiye haritası kullanılır.
*/
export function uniUlkeKonum(
universite: string,
il: string | null,
): UlkeKonum | null {
const ad = universite
.replace(/\s*\([^)]*\)\s*$/, "")
.trim()
.toLocaleUpperCase("tr-TR");
if (ad in UNI_DUZELTME) {
const anahtar = UNI_DUZELTME[ad];
return anahtar === null ? null : sehirdenKonum(anahtar);
}
if (!il || !YURTDISI_IL.has(il)) return null;
if (il in SEHIRLER) return sehirdenKonum(il as keyof typeof SEHIRLER);
// il "KIBRIS": ülke bilinir, şehir bilinmez — haritayı işaretsiz göster
const harita = ULKE_HARITALARI.kktc;
return {
ulke: "kktc",
ulkeAd: harita.ad,
iso: ULKE_ISO.kktc,
harita,
sehir: null,
};
}

View File

@@ -259,16 +259,23 @@ export function getAllUniversiteler(): UniOzet[] {
const map = uniMapGetir(); const map = uniMapGetir();
const rows = getDb() const rows = getDb()
.prepare( .prepare(
`SELECT universite, MAX(il) AS il, MAX(unitur) AS unitur, `SELECT universite, MAX(unitur) AS unitur, COUNT(*) AS adet
COUNT(*) AS adet
FROM programs GROUP BY universite`, FROM programs GROUP BY universite`,
) )
.all() as { .all() as {
universite: string; universite: string;
il: string | null;
unitur: string | null; unitur: string | null;
adet: number; adet: number;
}[]; }[];
// il: MAX(il) alfabetik en büyüğü seçip yanıltıyordu (ör. Ankara Sosyal
// Bilimler'in 12 KKTC satırı 47 Ankara satırını eziyordu); üniversitenin
// en çok programının bulunduğu il esas alınır.
const ilRows = getDb()
.prepare(
`SELECT universite, il, COUNT(*) AS adet FROM programs
WHERE il IS NOT NULL GROUP BY universite, il`,
)
.all() as { universite: string; il: string; adet: number }[];
// Fakülte sayısı: aynı üniversitenin iki yazımı ("X ÜNİV." / "X ÜNİV. (İL)") // Fakülte sayısı: aynı üniversitenin iki yazımı ("X ÜNİV." / "X ÜNİV. (İL)")
// birleştiği için GROUP BY universite üzerinden saymak çift sayardı; // birleştiği için GROUP BY universite üzerinden saymak çift sayardı;
// (slug, fakulte) çifti üzerinden kesin sayılır. // (slug, fakulte) çifti üzerinden kesin sayılır.
@@ -296,9 +303,30 @@ export function getAllUniversiteler(): UniOzet[] {
if (!slug) continue; if (!slug) continue;
const u = bySlug.get(slug)!; const u = bySlug.get(slug)!;
u.programSayisi += row.adet; u.programSayisi += row.adet;
if (!u.il && row.il) u.il = row.il;
if (!u.unitur && row.unitur) u.unitur = row.unitur; if (!u.unitur && row.unitur) u.unitur = row.unitur;
} }
const ilAdetleri = new Map<string, Map<string, number>>();
for (const row of ilRows) {
const slug = slugByHam.get(row.universite);
if (!slug) continue;
let sayac = ilAdetleri.get(slug);
if (!sayac) {
sayac = new Map();
ilAdetleri.set(slug, sayac);
}
sayac.set(row.il, (sayac.get(row.il) ?? 0) + row.adet);
}
for (const [slug, sayac] of ilAdetleri) {
let enIyi: string | null = null;
let enCok = 0;
for (const [il, adet] of sayac) {
if (adet > enCok) {
enIyi = il;
enCok = adet;
}
}
bySlug.get(slug)!.il = enIyi;
}
const fakulteSetleri = new Map<string, Set<string>>(); const fakulteSetleri = new Map<string, Set<string>>();
for (const row of fakulteRows) { for (const row of fakulteRows) {
const slug = slugByHam.get(row.universite); const slug = slugByHam.get(row.universite);