Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
160 lines
4.8 KiB
TypeScript
160 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
import { motion, useMotionValue } from "motion/react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
export default function CustomCursor() {
|
|
const [isHovering, setIsHovering] = useState(false);
|
|
const [isClicking, setIsClicking] = useState(false);
|
|
const [hoverText, setHoverText] = useState("");
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const cursorX = useMotionValue(0);
|
|
const cursorY = useMotionValue(0);
|
|
|
|
const rafRef = useRef<number>(0);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: MouseEvent) => {
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = requestAnimationFrame(() => {
|
|
cursorX.set(e.clientX);
|
|
cursorY.set(e.clientY);
|
|
if (!isVisible) setIsVisible(true);
|
|
});
|
|
},
|
|
[cursorX, cursorY, isVisible],
|
|
);
|
|
|
|
useEffect(() => {
|
|
// Only show custom cursor on desktop with fine pointer
|
|
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
|
|
if (!hasFinePointer) return;
|
|
|
|
const handleDown = () => setIsClicking(true);
|
|
const handleUp = () => setIsClicking(false);
|
|
const handleLeave = () => setIsVisible(false);
|
|
const handleEnter = () => setIsVisible(true);
|
|
|
|
window.addEventListener("mousemove", handleMouseMove);
|
|
window.addEventListener("mousedown", handleDown);
|
|
window.addEventListener("mouseup", handleUp);
|
|
document.addEventListener("mouseleave", handleLeave);
|
|
document.addEventListener("mouseenter", handleEnter);
|
|
|
|
// Detect hoverable elements
|
|
const observer = new MutationObserver(() => {
|
|
attachHoverListeners();
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
attachHoverListeners();
|
|
|
|
function attachHoverListeners() {
|
|
const hoverables = document.querySelectorAll(
|
|
'a, button, [role="button"], input, textarea, select, [data-cursor]',
|
|
);
|
|
for (const el of hoverables) {
|
|
el.addEventListener("mouseenter", handleElementEnter as EventListener);
|
|
el.addEventListener("mouseleave", handleElementLeave as EventListener);
|
|
}
|
|
}
|
|
|
|
function handleElementEnter(e: Event) {
|
|
const el = e.currentTarget as HTMLElement;
|
|
setIsHovering(true);
|
|
const cursorData = el.getAttribute("data-cursor");
|
|
if (cursorData) setHoverText(cursorData);
|
|
}
|
|
|
|
function handleElementLeave() {
|
|
setIsHovering(false);
|
|
setHoverText("");
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("mousedown", handleDown);
|
|
window.removeEventListener("mouseup", handleUp);
|
|
document.removeEventListener("mouseleave", handleLeave);
|
|
document.removeEventListener("mouseenter", handleEnter);
|
|
observer.disconnect();
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
};
|
|
}, [handleMouseMove]);
|
|
|
|
// Don't render on touch devices
|
|
if (
|
|
typeof window !== "undefined" &&
|
|
!window.matchMedia("(pointer: fine)").matches
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<style jsx global>{`
|
|
@media (pointer: fine) {
|
|
* { cursor: none !important; }
|
|
}
|
|
`}</style>
|
|
|
|
{/* Dot cursor */}
|
|
<motion.div
|
|
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
|
|
style={{
|
|
x: cursorX,
|
|
y: cursorY,
|
|
translateX: "-50%",
|
|
translateY: "-50%",
|
|
}}
|
|
animate={{
|
|
scale: isClicking ? 0.5 : isHovering ? 0.5 : 1,
|
|
opacity: isVisible ? 1 : 0,
|
|
}}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
<div
|
|
className="rounded-full bg-[#ccff00]"
|
|
style={{ width: 8, height: 8 }}
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* Ring cursor */}
|
|
<motion.div
|
|
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
|
|
style={{
|
|
x: cursorX,
|
|
y: cursorY,
|
|
translateX: "-50%",
|
|
translateY: "-50%",
|
|
}}
|
|
animate={{
|
|
width: isHovering ? 80 : 40,
|
|
height: isHovering ? 80 : 40,
|
|
opacity: isVisible ? (isHovering ? 0.8 : 0.4) : 0,
|
|
borderWidth: isHovering ? 2 : 1,
|
|
}}
|
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
|
>
|
|
<div
|
|
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
|
|
style={{
|
|
borderStyle: "solid",
|
|
borderColor: "#ccff00",
|
|
borderWidth: "inherit",
|
|
}}
|
|
>
|
|
{hoverText && (
|
|
<motion.span
|
|
initial={{ opacity: 0, scale: 0.5 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="text-[9px] font-bold uppercase tracking-widest text-[#ccff00]"
|
|
>
|
|
{hoverText}
|
|
</motion.span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
);
|
|
}
|