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:
208
src/features/pazarlama/components/suzulen-haplar.tsx
Normal file
208
src/features/pazarlama/components/suzulen-haplar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user