Files
kolaytercih/src/features/pazarlama/components/suzulen-haplar.tsx
bilalgursen 3c995402c6 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>
2026-10-07 17:12:53 +03:00

209 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}