All checks were successful
Deploy / deploy (push) Successful in 2m38s
- Updated the `page.tsx` component to enhance user experience with new UI elements and improved layout. - Modified the `funnel-banner.tsx` to include dynamic text and improved styling for better engagement. - Adjusted the `liste-paneli.tsx` to provide clearer information on user credits and package details. - Enhanced the `rapor-listesi.tsx` to display additional data points for better insights. - Updated the database schema in `app.db` to reflect recent changes in the application logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
"use client";
|
||
|
||
// Sonuç sayfasının kahraman öğesi: 24 tercihin Türkiye haritası üzerinde
|
||
// risk renkli pinlerle gösterimi. Ülke seviyesinde il başına tek toplu pin;
|
||
// ile tıklayınca viewBox o ilin sınırlarına animasyonla akar, il büyür ve
|
||
// üniversite pinleri gerçek kampüs konumlarında adlarıyla görünür.
|
||
// Kilitli (paketsiz) tercihler il merkezinde adsız "?" pini olarak çizilir —
|
||
// üniversite adı client'a hiç inmediği için paywall haritadan delinemez.
|
||
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { ArrowLeft, Lock } from "lucide-react";
|
||
import { cities } from "turkey-map-react/lib/data";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
HARITA_GENISLIK,
|
||
HARITA_UST_KIRPMA,
|
||
HARITA_YUKSEKLIK,
|
||
normalizeIlAdi,
|
||
} from "@/lib/harita";
|
||
import type { RiskSeviyesi } from "@/lib/risk";
|
||
|
||
export type HaritaPin = {
|
||
/** Üniversite bazlı benzersiz anahtar (kilitlilerde sentetik) */
|
||
id: string;
|
||
/** Görünen ad — kilitli pinlerde boş bırakılır */
|
||
universite: string;
|
||
/** DB biçiminde il adı (büyük harf); harita dışıysa null */
|
||
il: string | null;
|
||
x: number;
|
||
y: number;
|
||
/** Bu üniversitedeki tercihlerin liste sıraları (1-24) */
|
||
tercihSiralari: number[];
|
||
risk: RiskSeviyesi;
|
||
kilitli?: boolean;
|
||
};
|
||
|
||
const RISK_RENK: Record<RiskSeviyesi, string> = {
|
||
guvenli: "oklch(0.696 0.17 162.5)", // emerald-500
|
||
"az-riskli": "oklch(0.769 0.188 70.1)", // amber-500
|
||
riskli: "oklch(0.637 0.237 25.3)", // red-500
|
||
};
|
||
|
||
const TAM_GORUNUM = {
|
||
x: 0,
|
||
y: HARITA_UST_KIRPMA,
|
||
w: HARITA_GENISLIK,
|
||
h: HARITA_YUKSEKLIK - HARITA_UST_KIRPMA,
|
||
};
|
||
|
||
type ViewBox = typeof TAM_GORUNUM;
|
||
|
||
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
|
||
function kisaUniAdi(ad: string): string {
|
||
const kisa = ad
|
||
.replace(/\s+(ÜNİVERSİTESİ|ENSTİTÜSÜ|MESLEK YÜKSEKOKULU)$/i, "")
|
||
.trim();
|
||
return kisa
|
||
.toLocaleLowerCase("tr-TR")
|
||
.split(" ")
|
||
.map((k) =>
|
||
k.length > 2 || k === "29"
|
||
? k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1)
|
||
: k,
|
||
)
|
||
.join(" ");
|
||
}
|
||
|
||
/** İldeki pinlerin çoğunluk riski (eşitlikte güvenli taraf). */
|
||
function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi {
|
||
const sayac: Record<RiskSeviyesi, number> = {
|
||
guvenli: 0,
|
||
"az-riskli": 0,
|
||
riskli: 0,
|
||
};
|
||
for (const p of pinler) sayac[p.risk] += p.tercihSiralari.length;
|
||
return (["guvenli", "az-riskli", "riskli"] as const).reduce((a, b) =>
|
||
sayac[b] > sayac[a] ? b : a,
|
||
);
|
||
}
|
||
|
||
/** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */
|
||
function cakismaAc(pinler: HaritaPin[]): HaritaPin[] {
|
||
const gruplar = new Map<string, HaritaPin[]>();
|
||
for (const p of pinler) {
|
||
const key = `${Math.round(p.x)}:${Math.round(p.y)}`;
|
||
const grup = gruplar.get(key);
|
||
if (grup) grup.push(p);
|
||
else gruplar.set(key, [p]);
|
||
}
|
||
const sonuc: HaritaPin[] = [];
|
||
for (const grup of gruplar.values()) {
|
||
if (grup.length === 1) {
|
||
sonuc.push(grup[0]);
|
||
continue;
|
||
}
|
||
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 });
|
||
});
|
||
}
|
||
return sonuc;
|
||
}
|
||
|
||
export function TercihHaritasi({
|
||
pinler,
|
||
seciliIl,
|
||
onSeciliIlDegisti,
|
||
onTercihSec,
|
||
}: {
|
||
pinler: HaritaPin[];
|
||
/** Zoom'lanacak il (DB biçimi) — liste tarafından da kontrol edilebilir */
|
||
seciliIl: string | null;
|
||
onSeciliIlDegisti: (il: string | null) => void;
|
||
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
|
||
onTercihSec?: (sira: number) => void;
|
||
}) {
|
||
const pathRefs = useRef(new Map<string, SVGPathElement>());
|
||
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
|
||
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
|
||
const animRef = useRef(0);
|
||
|
||
const animateTo = useCallback((hedef: ViewBox) => {
|
||
cancelAnimationFrame(animRef.current);
|
||
const kaynak = { ...vbRef.current };
|
||
const t0 = performance.now();
|
||
const SURE = 380;
|
||
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
|
||
const adim = (now: number) => {
|
||
const t = Math.min(1, (now - t0) / SURE);
|
||
const k = ease(t);
|
||
const sonraki = {
|
||
x: kaynak.x + (hedef.x - kaynak.x) * k,
|
||
y: kaynak.y + (hedef.y - kaynak.y) * k,
|
||
w: kaynak.w + (hedef.w - kaynak.w) * k,
|
||
h: kaynak.h + (hedef.h - kaynak.h) * k,
|
||
};
|
||
vbRef.current = sonraki;
|
||
setVb(sonraki);
|
||
if (t < 1) animRef.current = requestAnimationFrame(adim);
|
||
};
|
||
animRef.current = requestAnimationFrame(adim);
|
||
}, []);
|
||
|
||
useEffect(() => () => cancelAnimationFrame(animRef.current), []);
|
||
|
||
// seciliIl değişince ilgili ilin bbox'ına (veya tam görünüme) ak
|
||
useEffect(() => {
|
||
if (!seciliIl) {
|
||
animateTo(TAM_GORUNUM);
|
||
return;
|
||
}
|
||
const path = pathRefs.current.get(seciliIl);
|
||
if (!path) return;
|
||
const b = path.getBBox();
|
||
const pad = Math.max(b.width, b.height) * 0.22;
|
||
let x = b.x - pad;
|
||
let y = b.y - pad;
|
||
let w = b.width + pad * 2;
|
||
let h = b.height + pad * 2;
|
||
// viewBox oranını koru ki il yamulmadan büyüsün ve harita yüksekliği
|
||
// zoom'da değişmesin (tam görünümle aynı oran)
|
||
const oran = TAM_GORUNUM.w / TAM_GORUNUM.h;
|
||
if (w / h > oran) {
|
||
const yeniH = w / oran;
|
||
y -= (yeniH - h) / 2;
|
||
h = yeniH;
|
||
} else {
|
||
const yeniW = h * oran;
|
||
x -= (yeniW - w) / 2;
|
||
w = yeniW;
|
||
}
|
||
animateTo({ x, y, w, h });
|
||
}, [seciliIl, animateTo]);
|
||
|
||
const ilPinleri = useMemo(() => {
|
||
const gruplar = new Map<string, HaritaPin[]>();
|
||
for (const p of pinler) {
|
||
if (!p.il) continue;
|
||
const grup = gruplar.get(p.il);
|
||
if (grup) grup.push(p);
|
||
else gruplar.set(p.il, [p]);
|
||
}
|
||
return gruplar;
|
||
}, [pinler]);
|
||
|
||
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
|
||
const sf = vb.w / HARITA_GENISLIK;
|
||
const zoomda = Boolean(seciliIl);
|
||
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
|
||
const acikPinler = cakismaAc(seciliPinler);
|
||
|
||
return (
|
||
<div>
|
||
<div className="relative">
|
||
<svg
|
||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
||
className="h-auto w-full"
|
||
role="img"
|
||
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
|
||
>
|
||
{/* İl zeminleri */}
|
||
{cities.map((city) => {
|
||
const key = normalizeIlAdi(city.name);
|
||
const iceriyor = ilPinleri.has(key);
|
||
const secili = key === seciliIl;
|
||
return (
|
||
<path
|
||
key={city.id}
|
||
ref={(el) => {
|
||
if (el) pathRefs.current.set(key, el);
|
||
else pathRefs.current.delete(key);
|
||
}}
|
||
d={city.path}
|
||
className={
|
||
iceriyor
|
||
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
|
||
: undefined
|
||
}
|
||
fill={
|
||
secili
|
||
? "oklch(0.97 0.014 254.6)" // blue-50: seçili zemin
|
||
: iceriyor
|
||
? "oklch(0.882 0.059 254.1)" // blue-200
|
||
: "oklch(0.929 0.013 255.5)" // slate-200
|
||
}
|
||
stroke="white"
|
||
strokeWidth={secili ? 1.6 * sf : 1 * sf}
|
||
onClick={() => {
|
||
if (secili) onSeciliIlDegisti(null);
|
||
else if (iceriyor) onSeciliIlDegisti(key);
|
||
}}
|
||
>
|
||
<title>
|
||
{iceriyor
|
||
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
|
||
(a, p) => a + p.tercihSiralari.length,
|
||
0,
|
||
)} tercih — yakınlaşmak için tıkla`
|
||
: city.name}
|
||
</title>
|
||
</path>
|
||
);
|
||
})}
|
||
|
||
{/* Ülke seviyesi: il başına toplu pin */}
|
||
{!zoomda
|
||
? [...ilPinleri.entries()].map(([il, iller]) => {
|
||
const adet = iller.reduce(
|
||
(a, p) => a + p.tercihSiralari.length,
|
||
0,
|
||
);
|
||
const cx =
|
||
iller.reduce((a, p) => a + p.x, 0) / iller.length;
|
||
const cy =
|
||
iller.reduce((a, p) => a + p.y, 0) / iller.length;
|
||
const renk = RISK_RENK[baskinRisk(iller)];
|
||
return (
|
||
<g
|
||
key={il}
|
||
className="cursor-pointer"
|
||
onClick={() => onSeciliIlDegisti(il)}
|
||
role="button"
|
||
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
|
||
>
|
||
<circle
|
||
cx={cx}
|
||
cy={cy}
|
||
r={11}
|
||
fill={renk}
|
||
stroke="white"
|
||
strokeWidth={2}
|
||
/>
|
||
<text
|
||
x={cx}
|
||
y={cy}
|
||
textAnchor="middle"
|
||
dominantBaseline="central"
|
||
fontSize={11}
|
||
fontWeight={700}
|
||
fill="white"
|
||
className="pointer-events-none select-none"
|
||
>
|
||
{adet}
|
||
</text>
|
||
</g>
|
||
);
|
||
})
|
||
: null}
|
||
|
||
{/* İl seviyesi: üniversite pinleri */}
|
||
{zoomda
|
||
? acikPinler.map((p) => {
|
||
const r = (p.kilitli ? 7 : 8) * sf;
|
||
const etiket = p.kilitli ? null : kisaUniAdi(p.universite);
|
||
return (
|
||
<g
|
||
key={p.id}
|
||
className={onTercihSec && !p.kilitli ? "cursor-pointer" : undefined}
|
||
onClick={
|
||
onTercihSec && !p.kilitli
|
||
? () => onTercihSec(p.tercihSiralari[0])
|
||
: undefined
|
||
}
|
||
role={p.kilitli ? undefined : "button"}
|
||
aria-label={
|
||
p.kilitli
|
||
? "Kilitli tercih"
|
||
: `${etiket}: ${p.tercihSiralari.length} tercih`
|
||
}
|
||
>
|
||
<circle
|
||
cx={p.x}
|
||
cy={p.y}
|
||
r={r}
|
||
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}
|
||
/>
|
||
<text
|
||
x={p.x}
|
||
y={p.y}
|
||
textAnchor="middle"
|
||
dominantBaseline="central"
|
||
fontSize={9 * sf}
|
||
fontWeight={700}
|
||
fill="white"
|
||
className="pointer-events-none select-none"
|
||
>
|
||
{p.kilitli ? "?" : p.tercihSiralari.length}
|
||
</text>
|
||
{etiket ? (
|
||
<text
|
||
x={p.x}
|
||
y={p.y - r - 4 * sf}
|
||
textAnchor="middle"
|
||
fontSize={10.5 * sf}
|
||
fontWeight={600}
|
||
fill="oklch(0.279 0.041 260.0)" // slate-800
|
||
stroke="white"
|
||
strokeWidth={3 * sf}
|
||
paintOrder="stroke"
|
||
className="pointer-events-none select-none"
|
||
>
|
||
{etiket}
|
||
</text>
|
||
) : null}
|
||
</g>
|
||
);
|
||
})
|
||
: null}
|
||
</svg>
|
||
|
||
{zoomda ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => onSeciliIlDegisti(null)}
|
||
className="absolute left-3 top-3 cursor-pointer rounded-full bg-white/90 backdrop-blur"
|
||
>
|
||
<ArrowLeft className="size-4" aria-hidden />
|
||
Türkiye
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* Seçili ilin tercih şeridi (yalnızca zoom'dayken) */}
|
||
{zoomda && seciliPinler.length > 0 ? (
|
||
<div className="mt-2">
|
||
<p className="px-1 pb-1 text-xs font-semibold text-slate-500">
|
||
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
|
||
</p>
|
||
<ul className="flex gap-2 overflow-x-auto pb-1">
|
||
{seciliPinler
|
||
.flatMap((p) =>
|
||
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
|
||
)
|
||
.sort((a, b) => a.sira - b.sira)
|
||
.map(({ pin, sira }) => (
|
||
<li key={sira} className="shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={onTercihSec ? () => onTercihSec(sira) : undefined}
|
||
className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1.5 pr-3 text-xs transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||
>
|
||
<span
|
||
className="flex size-5 items-center justify-center rounded-full font-heading text-[10px] font-bold text-white"
|
||
style={{ backgroundColor: RISK_RENK[pin.risk] }}
|
||
>
|
||
{sira}
|
||
</span>
|
||
{pin.kilitli ? (
|
||
<span className="inline-flex items-center gap-1 text-slate-400">
|
||
<Lock className="size-3" aria-hidden />
|
||
Kilitli tercih
|
||
</span>
|
||
) : (
|
||
<span className="max-w-40 truncate font-medium text-slate-700">
|
||
{kisaUniAdi(pin.universite)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|