Compare commits
2 Commits
24f53ddbce
...
ce6871d623
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce6871d623 | ||
|
|
3a8c049092 |
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
209
scripts/ulke-harita-uret.ts
Normal file
209
scripts/ulke-harita-uret.ts
Normal 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();
|
||||
@@ -36,7 +36,8 @@ export function GET(request: NextRequest) {
|
||||
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 = [
|
||||
...new Set(searchParams.getAll("il").filter((i) => i.trim().length > 0)),
|
||||
].slice(0, 5);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { PUAN_TURLERI, type PuanTuruKey } from "@/types/yokatlas";
|
||||
import {
|
||||
cerezdenTercihProfili,
|
||||
PROFIL_CEREZ_ADI,
|
||||
} from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import {
|
||||
SonucProgramTablosu,
|
||||
SonucProgramTablosuSkeleton,
|
||||
@@ -14,9 +19,9 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tercih Listeni Kur",
|
||||
// Param uzayı sonsuz + içerik sıralamaya göre kişisel: indexlenmez ama
|
||||
// link değeri follow ile geri akar. robots.txt'te ENGELLEME — noindex'in
|
||||
// görülebilmesi için sayfa taranabilir kalmalı.
|
||||
// İçerik profil çerezine göre kişisel (eski linklerde param uzayı sonsuz):
|
||||
// indexlenmez ama link değeri follow ile geri akar. robots.txt'te
|
||||
// ENGELLEME — noindex'in görülebilmesi için sayfa taranabilir kalmalı.
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
@@ -47,28 +52,37 @@ export default function SonucPage({
|
||||
</main>
|
||||
}
|
||||
>
|
||||
{searchParams.then((params) => {
|
||||
const sira = Number.parseInt(params.sira ?? "", 10);
|
||||
{Promise.all([searchParams, cookies()]).then(([params, cerezler]) => {
|
||||
// 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 =
|
||||
params.tur && params.tur in PUAN_TURLERI
|
||||
? (params.tur as PuanTuruKey)
|
||||
: "say";
|
||||
// Sihirbaz seçimlerinden gelen tablo filtreleri (bkz. sonucHref):
|
||||
// il tekrarlı param, tip devlet|vakif allowlist'li
|
||||
turHam && turHam in PUAN_TURLERI ? (turHam as PuanTuruKey) : "say";
|
||||
// Tablo filtreleri: il en fazla 5 tekrarsız, tip devlet|vakif
|
||||
// allowlist'li (çerez tarafı tercihProfiliDogrula'dan zaten geçmiş
|
||||
// olsa da iki kaynak aynı süzgeçten akar).
|
||||
const hamIller = eskiLink
|
||||
? Array.isArray(params.il)
|
||||
? params.il
|
||||
: params.il
|
||||
? [params.il]
|
||||
: []
|
||||
: (profil?.secimler.iller ?? []);
|
||||
const iller = [
|
||||
...new Set(
|
||||
(Array.isArray(params.il)
|
||||
? params.il
|
||||
: params.il
|
||||
? [params.il]
|
||||
: []
|
||||
).filter((i) => i.trim().length > 0),
|
||||
),
|
||||
...new Set(hamIller.filter((i) => i.trim().length > 0)),
|
||||
].slice(0, 5);
|
||||
const tipHam = eskiLink ? params.tip : profil?.secimler.universiteTipi;
|
||||
const uniturGrubu =
|
||||
params.tip === "devlet" || params.tip === "vakif"
|
||||
? params.tip
|
||||
: undefined;
|
||||
tipHam === "devlet" || tipHam === "vakif" ? tipHam : undefined;
|
||||
|
||||
if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) {
|
||||
return (
|
||||
@@ -90,7 +104,7 @@ export default function SonucPage({
|
||||
{/* 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
|
||||
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
|
||||
sira={sira}
|
||||
tur={turKey}
|
||||
|
||||
@@ -82,14 +82,16 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
|
||||
|
||||
function sihirbazaGit() {
|
||||
onSec();
|
||||
// Orkestratör (SihirbazBolumu) yalnızca /sonuc?sira= geçerliyken monte;
|
||||
// sırasız /sonuc'ta (SiraGerekli görünümü) olay boşa gider — kapıya düş.
|
||||
if (pathname === "/sonuc" && searchParams.get("sira")) {
|
||||
// Orkestratör (SihirbazBolumu) yalnızca /sonuc tabloyu render ederken
|
||||
// monte: profil çerezi (client'ta aynası localStorage profili) ya da eski
|
||||
// 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));
|
||||
return;
|
||||
}
|
||||
if (profil) {
|
||||
router.push(sonucHref(profil, { sihirbaz: "1" }));
|
||||
router.push(sonucHref({ sihirbaz: "1" }));
|
||||
return;
|
||||
}
|
||||
// Yapay Zeka niyetli CTA: kapı tamamlanınca /sonuc'ta üretim akışı devam
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SiteFooter } from "@/components/site-footer";
|
||||
import { Sayfalama, SAYFA_BOYU } from "./sayfalama";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { UniversiteKonumHaritasi } from "./universite-konum-haritasi";
|
||||
import { uniUlkeKonum } from "@/lib/harita-ulkeler";
|
||||
import { UniversiteProgramTablosu } from "./universite-program-tablosu";
|
||||
import { UniLogo } from "@/components/uni-logo";
|
||||
import { uniLogoYolu } from "@/lib/uni-logolar";
|
||||
@@ -94,6 +95,7 @@ export function UniversiteIcerik({
|
||||
const buYol = ilkSayfa
|
||||
? `/universite/${slug}`
|
||||
: `/universite/${slug}/sayfa/${sayfa}`;
|
||||
const yurtdisi = uniUlkeKonum(uni.ad, uni.il);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -116,12 +118,16 @@ export function UniversiteIcerik({
|
||||
...(uniLogoYolu(slug)
|
||||
? { logo: `${SITE_URL}${uniLogoYolu(slug)}` }
|
||||
: {}),
|
||||
...(uni.il
|
||||
...(uni.il || yurtdisi
|
||||
? {
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: trBaslikDuzeni(uni.il),
|
||||
addressCountry: "TR",
|
||||
...(yurtdisi?.sehir
|
||||
? { addressLocality: yurtdisi.sehir }
|
||||
: uni.il
|
||||
? { addressLocality: trBaslikDuzeni(uni.il) }
|
||||
: {}),
|
||||
addressCountry: yurtdisi ? yurtdisi.iso : "TR",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
HARITA_YUKSEKLIK,
|
||||
normalizeIlAdi,
|
||||
} 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({
|
||||
universite,
|
||||
@@ -13,8 +22,55 @@ export function UniversiteKonumHaritasi({
|
||||
universite: string;
|
||||
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;
|
||||
|
||||
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 (
|
||||
<section
|
||||
aria-labelledby="universite-konumu"
|
||||
@@ -23,34 +79,34 @@ export function UniversiteKonumHaritasi({
|
||||
<h2 id="universite-konumu" className="sr-only">
|
||||
{universite} kampüs konumu
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<div className="relative h-full min-h-44 w-full">{children}</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ export function ProgramTablosu({
|
||||
const [aktifDilim, setAktifDilim] = useState<DilimKey>(() =>
|
||||
varsayilanDilim(sonuclar),
|
||||
);
|
||||
// Sihirbaz seçimlerinden URL'e yazılan filtreler (bkz. sonucHref) —
|
||||
// sayfalama isteklerinde ve veri anahtarında kullanılır.
|
||||
// Sihirbaz seçimlerinden profil çerezine yazılıp SSR'da prop olarak gelen
|
||||
// filtreler — sayfalama isteklerinde ve veri anahtarında kullanılır.
|
||||
const iller = useMemo(() => filtreIller ?? [], [filtreIller]);
|
||||
|
||||
// "Daha fazla göster" ile sayfalanan devam satırları. Sıra/tür/filtre
|
||||
|
||||
@@ -81,7 +81,7 @@ export function SecimlerimPaneli() {
|
||||
asChild
|
||||
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
|
||||
|
||||
@@ -93,7 +93,7 @@ function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi {
|
||||
}
|
||||
|
||||
/** 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[]>();
|
||||
for (const p of pinler) {
|
||||
const key = `${Math.round(p.x)}:${Math.round(p.y)}`;
|
||||
@@ -109,7 +109,11 @@ function cakismaAc(pinler: HaritaPin[]): HaritaPin[] {
|
||||
}
|
||||
grup.forEach((p, i) => {
|
||||
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;
|
||||
@@ -243,7 +247,12 @@ export function TercihHaritasi({
|
||||
() => (seciliIl ? (ilPinleri.get(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
|
||||
// (üç 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
|
||||
// client'a inmediği gibi görselden de sızmamalı.
|
||||
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);
|
||||
return (
|
||||
<g
|
||||
@@ -396,31 +405,31 @@ export function TercihHaritasi({
|
||||
r={r}
|
||||
fill="white"
|
||||
stroke={RISK_RENK[p.risk]}
|
||||
strokeWidth={2 * sf}
|
||||
strokeWidth={2.5 * sf}
|
||||
/>
|
||||
<image
|
||||
href={logo}
|
||||
x={p.x - 8 * sf}
|
||||
y={p.y - 8 * sf}
|
||||
width={16 * sf}
|
||||
height={16 * sf}
|
||||
x={p.x - 15 * sf}
|
||||
y={p.y - 15 * sf}
|
||||
width={30 * sf}
|
||||
height={30 * sf}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<circle
|
||||
cx={p.x + r * 0.78}
|
||||
cy={p.y - r * 0.78}
|
||||
r={4.5 * sf}
|
||||
r={7.5 * sf}
|
||||
fill={RISK_RENK[p.risk]}
|
||||
stroke="white"
|
||||
strokeWidth={1.2 * sf}
|
||||
strokeWidth={1.5 * sf}
|
||||
/>
|
||||
<text
|
||||
x={p.x + r * 0.78}
|
||||
y={p.y - r * 0.78}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={6 * sf}
|
||||
fontSize={9.5 * sf}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
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]}
|
||||
stroke="white"
|
||||
strokeWidth={2 * sf}
|
||||
strokeDasharray={p.kilitli ? `${3 * sf} ${2.5 * sf}` : undefined}
|
||||
strokeDasharray={p.kilitli ? `${4 * sf} ${3 * sf}` : undefined}
|
||||
/>
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={9 * sf}
|
||||
fontSize={13 * sf}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
@@ -456,13 +465,13 @@ export function TercihHaritasi({
|
||||
{etiket ? (
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y - r - 4 * sf}
|
||||
y={p.y - r - 6 * sf}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5 * sf}
|
||||
fontSize={16 * sf}
|
||||
fontWeight={600}
|
||||
fill="oklch(0.279 0.041 260.0)" // slate-800
|
||||
stroke="white"
|
||||
strokeWidth={3 * sf}
|
||||
strokeWidth={4 * sf}
|
||||
paintOrder="stroke"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
|
||||
@@ -199,11 +199,14 @@ function KapıIcerik({
|
||||
if (!duzenle && !bekleyenVar && hedef !== "sihirbaz") {
|
||||
toast.success("Tercihlerin kaydedildi.");
|
||||
}
|
||||
// Düzenlemede replace: aynı /sonuc sayfasında sira/tur değişince geçmiş
|
||||
// şişmesin; ilk kurulumda push ile hedefe gitsin.
|
||||
// Düzenlemede replace (geçmiş şişmesin), ilk kurulumda push. Profil
|
||||
// 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 (duzenle) router.replace(yonlendir);
|
||||
else router.push(yonlendir);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ export function sonucaGitIste(secenekler?: { dogrudanSira?: boolean }): {
|
||||
if (!durum.profilYuklendi) yukle();
|
||||
const profil = durum.profil ?? tercihProfiliOku();
|
||||
if (profil) {
|
||||
return { href: sonucHref(profil), kapiAcildi: false };
|
||||
return { href: sonucHref(), kapiAcildi: false };
|
||||
}
|
||||
profilToplamaAc("sonuc", secenekler?.dogrudanSira ?? false);
|
||||
return { href: null, kapiAcildi: true };
|
||||
@@ -245,7 +245,6 @@ export function profilTamamlandi(
|
||||
profil: TercihProfili,
|
||||
girisli = false,
|
||||
): string | null {
|
||||
const onceki = durum.profil;
|
||||
const duzenle = durum.duzenle;
|
||||
riskleriYenile(profil);
|
||||
const bekleyen = durum.bekleyen;
|
||||
@@ -268,20 +267,14 @@ export function profilTamamlandi(
|
||||
// Yapay Zeka niyetli giriş: kapıda sihirbazı zaten doldurdu, /sonuc'ta
|
||||
// akış kesilmesin — girişliyse üretim otomatik başlar (?sihirbaz=1),
|
||||
// girişsizse ücretsiz tablo + giriş CTA paneli (?hazir=1) karşılar.
|
||||
return sonucHref(profil, 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);
|
||||
return sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" });
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function RaporYokCta() {
|
||||
asChild
|
||||
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'lük listeni oluştur
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function KapanisCta() {
|
||||
size="lg"
|
||||
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
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
|
||||
@@ -240,7 +240,7 @@ export function DemoKapanisCta() {
|
||||
: "Kuralları öğrendin — şimdi kendi sıralamanla dene."}
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
{profil ? "Sıralamana uygun programları gör" : "Sıralamanı gir"}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function ListeUretici({
|
||||
{profil ? (
|
||||
<p className="mt-4 text-xs text-slate-500">
|
||||
Seçimlerin kayıtlı —{" "}
|
||||
<Link href={sonucHref(profil)} className="underline">
|
||||
<Link href={sonucHref()} className="underline">
|
||||
sonuç sayfasına dönebilirsin
|
||||
</Link>
|
||||
.
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ListemBosCta() {
|
||||
size="lg"
|
||||
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"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function TadimlikSatiri({
|
||||
const seri = raporSiraSerisi(p.siraGecmisi);
|
||||
const taban = p.efektifSira ?? programEtkinSira(seri);
|
||||
const devlet = p.unitur === "DEVLET";
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref({ sira, tur }))}`;
|
||||
const girisUrl = `/giris?callback=${encodeURIComponent(sonucHref())}`;
|
||||
|
||||
return (
|
||||
<section ref={gorunumRef} className="mt-16 min-w-0">
|
||||
|
||||
@@ -123,9 +123,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
const yeniProfil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(yeniProfil);
|
||||
setAcik(false);
|
||||
router.push(
|
||||
sonucHref(yeniProfil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
|
||||
);
|
||||
router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" }));
|
||||
}
|
||||
|
||||
function acikDegisti(v: boolean) {
|
||||
@@ -150,7 +148,7 @@ export function CtaSiraForm({ baslik }: { baslik?: string }) {
|
||||
programların hazır.
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
Sıralamana uygun programları gör
|
||||
|
||||
@@ -100,9 +100,7 @@ export function HeroForm() {
|
||||
const profil = { sira, tur, secimler };
|
||||
tercihProfiliYaz(profil);
|
||||
setModalAcik(false);
|
||||
router.push(
|
||||
sonucHref(profil, girisli ? { sihirbaz: "1" } : { hazir: "1" }),
|
||||
);
|
||||
router.push(sonucHref(girisli ? { sihirbaz: "1" } : { hazir: "1" }));
|
||||
}
|
||||
|
||||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||||
@@ -116,7 +114,7 @@ export function HeroForm() {
|
||||
size="lg"
|
||||
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
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
|
||||
@@ -110,6 +110,9 @@ export function SihirbazAdimlar({
|
||||
// eşleşme için iki taraf da normalizeIlAdi'dan geçirilir.
|
||||
const [ilArama, setIlArama] = useState("");
|
||||
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(
|
||||
() =>
|
||||
ilAramaNorm
|
||||
@@ -119,6 +122,14 @@ export function SihirbazAdimlar({
|
||||
: turkiyeIlleri,
|
||||
[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(
|
||||
() =>
|
||||
ilAramaNorm
|
||||
@@ -314,15 +325,16 @@ export function SihirbazAdimlar({
|
||||
|
||||
{/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde
|
||||
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
|
||||
programı olan TÜM iller görünür (kırpma yok); arama yalnızca
|
||||
görünümü daraltır, seçimleri etkilemez. */}
|
||||
süzülmüş, program sayısına göre sıralı listelenir. Varsayılan
|
||||
görünüm ilk 10 il; kalanı "+N il daha" rozetiyle açılır. Arama
|
||||
yalnızca görünümü daraltır, seçimleri etkilemez ve kırpmayı
|
||||
devre dışı bırakır. */}
|
||||
<div
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
{gorunenTurkiyeIlleri.map((i) => (
|
||||
{listelenenTurkiyeIlleri.map((i) => (
|
||||
<Cip
|
||||
key={i.il}
|
||||
kucuk
|
||||
@@ -333,6 +345,27 @@ export function SihirbazAdimlar({
|
||||
<span className="ml-1 text-[10px] opacity-70">{i.adet}</span>
|
||||
</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>
|
||||
|
||||
{ilAramaNorm &&
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
// /sonuc?sira= yokken: kayıtlı profil varsa oraya taşı; yoksa kapıyı aç.
|
||||
// Sıra bilgisi localStorage'daki TercihProfili ile tüm uygulamada ortak.
|
||||
// Profil çerezi yokken: localStorage'da kayıtlı profil varsa çereze taşıyıp
|
||||
// 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 { useRouter } from "next/navigation";
|
||||
@@ -9,7 +10,7 @@ import Link from "next/link";
|
||||
import { ArrowLeft, Trophy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PagePixelDivider } from "@/components/pixel-decor";
|
||||
import { sonucHref } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { profilCerezineYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
import {
|
||||
sonucaGitIste,
|
||||
useProfilKapisi,
|
||||
@@ -20,9 +21,14 @@ export function SiraGerekli() {
|
||||
const { profil, profilYuklendi } = useProfilKapisi();
|
||||
|
||||
// "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(() => {
|
||||
if (profilYuklendi && profil) router.replace(sonucHref(profil));
|
||||
if (profilYuklendi && profil) {
|
||||
profilCerezineYaz(profil);
|
||||
router.refresh();
|
||||
}
|
||||
}, [profil, profilYuklendi, router]);
|
||||
|
||||
if (!profilYuklendi || profil) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Şema, doğrulama ve sabitler sihirbaz-sabitler.ts'te yaşar.
|
||||
|
||||
import {
|
||||
PROFIL_CEREZ_ADI,
|
||||
PROFIL_DEGISTI_EVENT,
|
||||
SIHIRBAZ_STORAGE_KEY,
|
||||
tercihProfiliDogrula,
|
||||
@@ -42,6 +43,24 @@ export function tercihProfiliOku(): TercihProfili | null {
|
||||
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. */
|
||||
export function tercihProfiliYaz(profil: TercihProfili): void {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -50,6 +69,7 @@ export function tercihProfiliYaz(profil: TercihProfili): void {
|
||||
try {
|
||||
localStorage.setItem(SIHIRBAZ_STORAGE_KEY, JSON.stringify(dogru));
|
||||
} catch {}
|
||||
profilCerezineYaz(dogru);
|
||||
profilCache = dogru;
|
||||
profilDegistiginiYayinla();
|
||||
}
|
||||
@@ -60,6 +80,7 @@ export function tercihProfiliSil(): void {
|
||||
try {
|
||||
localStorage.removeItem(SIHIRBAZ_STORAGE_KEY);
|
||||
} catch {}
|
||||
profilCereziniSil();
|
||||
profilCache = null;
|
||||
profilDegistiginiYayinla();
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ export const PUAN_TURU_ETIKET: Record<string, string> = {
|
||||
// dönene kadar burada bekler.
|
||||
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
|
||||
// aboneler (ör. navbar sıralama rozeti) bu olayla haberdar edilir.
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Profildeki sıra/tur ile sonuç sayfası URL'i — tüm uygulama aynı sorguyu
|
||||
* kullanır. Profilde sihirbaz seçimleri varsa il + üniversite tipi de URL'e
|
||||
* yazılır: /sonuc'un ham tablosu SSR'da bu paramlarla filtrelenir (tek
|
||||
* doğruluk kaynağı URL; localStorage profili yalnızca URL'i üretir).
|
||||
*/
|
||||
export function sonucHref(
|
||||
profil: Pick<TercihProfili, "sira" | "tur"> & {
|
||||
secimler?: SihirbazSecimleri;
|
||||
},
|
||||
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);
|
||||
}
|
||||
/** /sonuc çerezindeki ham değeri güvenle profile çözer (SSR tarafı). */
|
||||
export function cerezdenTercihProfili(
|
||||
ham: string | undefined | null,
|
||||
): TercihProfili | null {
|
||||
if (!ham) return null;
|
||||
try {
|
||||
return tercihProfiliDogrula(JSON.parse(decodeURIComponent(ham)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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. */
|
||||
|
||||
62
src/lib/harita-ulkeler-verisi.ts
Normal file
62
src/lib/harita-ulkeler-verisi.ts
Normal file
File diff suppressed because one or more lines are too long
115
src/lib/harita-ulkeler.ts
Normal file
115
src/lib/harita-ulkeler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -259,16 +259,23 @@ export function getAllUniversiteler(): UniOzet[] {
|
||||
const map = uniMapGetir();
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT universite, MAX(il) AS il, MAX(unitur) AS unitur,
|
||||
COUNT(*) AS adet
|
||||
`SELECT universite, MAX(unitur) AS unitur, COUNT(*) AS adet
|
||||
FROM programs GROUP BY universite`,
|
||||
)
|
||||
.all() as {
|
||||
universite: string;
|
||||
il: string | null;
|
||||
unitur: string | null;
|
||||
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)")
|
||||
// birleştiği için GROUP BY universite üzerinden saymak çift sayardı;
|
||||
// (slug, fakulte) çifti üzerinden kesin sayılır.
|
||||
@@ -296,9 +303,30 @@ export function getAllUniversiteler(): UniOzet[] {
|
||||
if (!slug) continue;
|
||||
const u = bySlug.get(slug)!;
|
||||
u.programSayisi += row.adet;
|
||||
if (!u.il && row.il) u.il = row.il;
|
||||
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>>();
|
||||
for (const row of fakulteRows) {
|
||||
const slug = slugByHam.get(row.universite);
|
||||
|
||||
Reference in New Issue
Block a user