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>
209 lines
6.8 KiB
TypeScript
209 lines
6.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|