feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını

Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-07 17:12:53 +03:00
parent bc920f6111
commit 3c995402c6
38 changed files with 5474 additions and 697 deletions

View File

@@ -0,0 +1,208 @@
"use client";
// Süzülen haplar (DESIGN.md §4.30): kataloğun en çok aranan bölüm ve şehir
// sayfalarına giden haplar. Bölüm ekrana girince haplar yukarıdan hafif
// eğik düşüp yerine oturur; imleç gezdikçe her hap kendi derinliğinde azıcık
// süzülür, arkadaki piksel kareler ters yöne kayar. Her hap gerçek bir
// linktir (§4.12 kimlik hapı, hover tek turuncu).
//
// Esin: React Bits "Parallax Pills" (imleci izleyen, sekerek giren etiketli
// haplar). Kod bize ait: haplar mutlak konumla değil akışla dizilir (375 px'te
// üst üste binmez), giriş tek ve hafif sekmeli (düşen nesne, §6), arka plan
// süs hapı yerine sitenin piksel kareleri. Dokunmatikte süzülme yoktur;
// hareketi azalt'ta haplar yalnız belirir.
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
import { GraduationCap, MapPin } from "lucide-react";
import { cn } from "@/lib/utils";
import type { KatalogHapi } from "@/features/pazarlama/landing-verisi";
/** İmlecin kenardan kenara taşıdığı en büyük kayma (px). */
const GUC = 18;
const SUZULME_YAYI = { stiffness: 120, damping: 20, mass: 0.6 };
/** Düşüş: yukarıdan gelir, tek kez hafif seker. */
const DUSUS = { type: "spring", duration: 0.7, bounce: 0.22 } as const;
const karma = (i: number, tohum: number) => {
const n = Math.sin(i * 127.1 + tohum * 311.7) * 43758.5453;
return n - Math.floor(n);
};
// Arkadaki piksel kareler: kenarlarda, orta boş (§10.8 doku kuralı). Konum %.
const KARELER = [
{ x: 4, y: 12, boy: 27, sicak: false },
{ x: 13, y: 78, boy: 17, sicak: true },
{ x: 24, y: 2, boy: 17, sicak: false },
{ x: 77, y: 0, boy: 27, sicak: false },
{ x: 90, y: 22, boy: 17, sicak: true },
{ x: 94, y: 70, boy: 37, sicak: false },
{ x: 70, y: 90, boy: 17, sicak: false },
{ x: 0, y: 52, boy: 17, sicak: false },
];
type Durum = "ssr" | "bekliyor" | "geldi";
function Hap({
hap,
sira,
durum,
azalt,
mx,
my,
}: {
hap: KatalogHapi;
sira: number;
durum: Durum;
azalt: boolean;
mx: MotionValue<number>;
my: MotionValue<number>;
}) {
// Her hap kendi derinliğinde: öndekiler çok, arkadakiler az kayar
const derinlik = 0.45 + karma(sira, 5) * 0.75;
const x = useTransform(mx, (v) => v * GUC * derinlik);
const y = useTransform(my, (v) => v * GUC * 0.7 * derinlik);
const egim = (karma(sira, 9) - 0.5) * 16;
const iri = sira % 3 === 0;
const Ikon = hap.tur === "sehir" ? MapPin : GraduationCap;
const gizli = azalt ? { opacity: 0 } : { opacity: 0, y: -36, rotate: egim };
return (
<motion.div style={{ x, y }}>
<motion.div
initial={false}
animate={durum === "bekliyor" ? gizli : { opacity: 1, y: 0, rotate: 0 }}
transition={
durum !== "geldi"
? { duration: 0 }
: azalt
? { duration: 0.2, delay: sira * 0.02 }
: { ...DUSUS, delay: sira * 0.045, opacity: { duration: 0.2, delay: sira * 0.045 } }
}
>
<Link
href={hap.href}
className={cn(
"inline-flex items-center gap-2 rounded-full border border-slate-200 bg-card font-semibold text-slate-800 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]",
iri ? "h-12 px-5 text-base" : "h-11 px-4 text-sm",
)}
>
<Ikon className="size-4 shrink-0 text-primary" aria-hidden />
{hap.etiket}
<span className="sr-only">
{hap.tur === "sehir" ? " üniversiteleri" : " taban puanları"}
</span>
</Link>
</motion.div>
</motion.div>
);
}
function ArkaKare({
kare,
mx,
my,
}: {
kare: (typeof KARELER)[number];
mx: MotionValue<number>;
my: MotionValue<number>;
}) {
// Arka katman ters yöne ve daha çok kayar: derinlik hissi
const x = useTransform(mx, (v) => -v * GUC * 1.4);
const y = useTransform(my, (v) => -v * GUC);
return (
<motion.span
className={cn(
"absolute rounded",
kare.sicak ? "bg-orange-500/70" : "bg-primary/15",
)}
style={{ left: `${kare.x}%`, top: `${kare.y}%`, width: kare.boy, height: kare.boy, x, y }}
/>
);
}
export function SuzulenHaplar({
haplar,
className,
}: {
haplar: KatalogHapi[];
className?: string;
}) {
const kapRef = useRef<HTMLDivElement>(null);
const azalt = useReducedMotion() ?? false;
// SSR'da haplar görünür çizilir (JS'siz ve bot için). Montajdan sonra bölüm
// henüz ekranda değilse gizlenir, girince düşer; zaten ekrandaysa dokunulmaz.
const [durum, setDurum] = useState<Durum>("ssr");
const hamX = useMotionValue(0);
const hamY = useMotionValue(0);
const mx = useSpring(hamX, SUZULME_YAYI);
const my = useSpring(hamY, SUZULME_YAYI);
useEffect(() => {
const kap = kapRef.current;
if (!kap || typeof IntersectionObserver === "undefined") return;
let ilk = true;
const io = new IntersectionObserver(
([kayit]) => {
if (kayit.isIntersecting) {
setDurum((d) => (d === "bekliyor" ? "geldi" : d));
io.disconnect();
} else if (ilk) {
setDurum("bekliyor");
}
ilk = false;
},
{ rootMargin: "0px 0px -80px 0px" },
);
io.observe(kap);
return () => io.disconnect();
}, []);
// İmleç: yalnız fare; kabın ortasına göre −0,5…0,5
useEffect(() => {
const kap = kapRef.current;
if (!kap || azalt || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
return;
}
const oynadi = (e: PointerEvent) => {
const kutu = kap.getBoundingClientRect();
hamX.set(Math.max(-0.5, Math.min(0.5, (e.clientX - kutu.left) / kutu.width - 0.5)));
hamY.set(Math.max(-0.5, Math.min(0.5, (e.clientY - kutu.top) / kutu.height - 0.5)));
};
const cikti = () => {
hamX.set(0);
hamY.set(0);
};
kap.addEventListener("pointermove", oynadi, { passive: true });
kap.addEventListener("pointerleave", cikti);
return () => {
kap.removeEventListener("pointermove", oynadi);
kap.removeEventListener("pointerleave", cikti);
};
}, [azalt, hamX, hamY]);
return (
<div ref={kapRef} className={cn("relative isolate px-2 py-10 sm:px-10", className)}>
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
{KARELER.map((kare) => (
<ArkaKare key={`${kare.x}-${kare.y}`} kare={kare} mx={mx} my={my} />
))}
</div>
<ul className="mx-auto flex max-w-2xl flex-wrap items-center justify-center gap-x-2.5 gap-y-3">
{haplar.map((hap, i) => (
<li key={hap.href}>
<Hap hap={hap} sira={i} durum={durum} azalt={azalt} mx={mx} my={my} />
</li>
))}
</ul>
</div>
);
}