"use client"; import { useState, useEffect, useRef, memo, useMemo } from "react"; interface BadgeProps { url?: string; width?: number; height?: number; textColor?: string; borderColor?: string; bgColor?: string; maxRotationX?: number; maxRotationY?: number; title?: string; subtitle?: string; } type Point = { x: number; y: number; }; const ANIMATION_CONFIG = { SPRING_STIFFNESS: 200, SPRING_DAMPING: 10, IDLE_ANIMATION_DURATION: 8, MAX_DELTA_TIME: 0.016, } as const; const RAINBOW_COLORS = [ "hsl(358, 100%, 62%)", "hsl(30, 100%, 50%)", "hsl(60, 100%, 50%)", "hsl(96, 100%, 50%)", "hsl(233, 85%, 47%)", "hsl(271, 85%, 47%)", "hsl(300, 20%, 35%)", "transparent", "transparent", "white", ] as const; const createMatrix = ({ x, y }: Point) => ({ scaleX: Math.cos((y * Math.PI) / 180), shearY1: 0, rotationY: Math.sin((y * Math.PI) / 180), perspective1: 0, rotationCompoundXY: Math.sin((x * Math.PI) / 180) * Math.sin((y * Math.PI) / 180), scaleY: Math.cos((x * Math.PI) / 180), rotationCompound: -Math.sin((x * Math.PI) / 180) * Math.cos((y * Math.PI) / 180), perspective2: 0, rotationX: -Math.sin((y * Math.PI) / 180), rotationZ: Math.sin((x * Math.PI) / 180), scaleZ: Math.cos((x * Math.PI) / 180) * Math.cos((y * Math.PI) / 180), perspective3: 0, translateX: 0, translateY: 0, translateZ: 0, perspectiveFactor: 1, }); function useAnimation(target: Point, isHovering: boolean) { const speed = useRef<Point>({ x: 0, y: 0 }); const [rotation, setRotation] = useState<Point>({ x: 0, y: 0 }); const frameRef = useRef<number>(null); const lastTimeRef = useRef(performance.now()); useEffect(() => { function animate() { const now = performance.now(); const delta = Math.min( (now - lastTimeRef.current) / 1000, ANIMATION_CONFIG.MAX_DELTA_TIME, ); lastTimeRef.current = now; // Calculate the current target based on interaction state const currentTarget = isHovering ? target : { // Create a figure-8 motion path for idle state x: Math.sin((now / 4000) * Math.PI), y: Math.sin((now / 2000) * Math.PI), }; // Spring physics const springForceX = (currentTarget.x - rotation.x) * ANIMATION_CONFIG.SPRING_STIFFNESS; const springForceY = (currentTarget.y - rotation.y) * ANIMATION_CONFIG.SPRING_STIFFNESS; const dampingForceX = -speed.current.x * ANIMATION_CONFIG.SPRING_DAMPING; const dampingForceY = -speed.current.y * ANIMATION_CONFIG.SPRING_DAMPING; // Update speed with spring and damping forces speed.current = { x: speed.current.x + (springForceX + dampingForceX) * delta, y: speed.current.y + (springForceY + dampingForceY) * delta, }; // Update position setRotation((prev) => ({ x: prev.x + speed.current.x * delta, y: prev.y + speed.current.y * delta, })); frameRef.current = requestAnimationFrame(animate); } frameRef.current = requestAnimationFrame(animate); return () => { if (frameRef.current) { cancelAnimationFrame(frameRef.current); } }; }, [target, rotation, isHovering]); return rotation; } function AwardLogo({ color }: { color: string }) { return ( <g transform="translate(8, 9)"> <path fill={color} d="M14.963 9.075c.787-3-.188-5.887-.188-5.887S12.488 5.175 11.7 8.175c-.787 3 .188 5.887.188 5.887s2.25-1.987 3.075-4.987m-4.5 1.987c.787 3-.188 5.888-.188 5.888S7.988 14.962 7.2 11.962c-.787-3 .188-5.887.188-5.887s2.287 1.987 3.075 4.987m.862 10.388s-.6-2.962-2.775-5.175C6.337 14.1 3.375 13.5 3.375 13.5s.6 2.962 2.775 5.175c2.213 2.175 5.175 2.775 5.175 2.775m3.3 3.413s-1.988-2.288-4.988-3.075-5.887.187-5.887.187 1.987 2.287 4.988 3.075c3 .787 5.887-.188 5.887-.188Zm6.75 0s1.988-2.288 4.988-3.075c3-.826 5.887.187 5.887.187s-1.988 2.287-4.988 3.075c-3 .787-5.887-.188-5.887-.188ZM32.625 13.5s-2.963.6-5.175 2.775c-2.213 2.213-2.775 5.175-2.775 5.175s2.962-.6 5.175-2.775c2.175-2.213 2.775-5.175 2.775-5.175M28.65 6.075s.975 2.887.188 5.887c-.826 3-3.076 4.988-3.076 4.988s-.974-2.888-.187-5.888c.788-3 3.075-4.987 3.075-4.987m-4.5 7.987s.975-2.887.188-5.887c-.788-3-3.076-4.988-3.076-4.988s-.974 2.888-.187 5.888c.788 3 3.075 4.988 3.075 4.988ZM18 26.1c.975-.225 3.113-.6 5.325 0 3 .788 5.063 3.038 5.063 3.038s-2.888.975-5.888.187a13 13 0 0 1-1.425-.525c.563.788 1.125 1.425 2.288 1.913l-.863 2.062c-2.063-.862-2.925-2.137-3.675-3.262-.262-.375-.525-.713-.787-1.05-.26.293-.465.586-.686.903l-.102.147-.048.068c-.775 1.108-1.643 2.35-3.627 3.194l-.862-2.062c1.162-.488 1.725-1.125 2.287-1.913-.45.225-.938.375-1.425.525-3 .788-5.887-.187-5.887-.187s1.987-2.288 4.987-3.075c2.212-.563 4.35-.188 5.325.037" /> </g> ); } function ShinyEffect({ width, height, shineAngle, }: { width: number; height: number; shineAngle: number; }) { return ( <g style={{ mixBlendMode: "overlay" }} mask="url(#badgeMask)"> {RAINBOW_COLORS.map((color, i) => ( <g key={i} style={{ transform: `rotate(${shineAngle * 3.2 + i * 10 - 20}deg)`, transformOrigin: "center center", }} > <polygon points={`0,0 ${width},${height} ${width},0 0,${height}`} fill={color} filter="url(#blur1)" opacity="0.5" /> </g> ))} </g> ); } export default function GoldenKittyBadge({ url = "https://www.producthunt.com/golden-kitty-awards/hall-of-fame?year=2024#maker-of-the-year-10", width = 260, height = 54, bgColor = "hsl(54, 100%, 49%)", borderColor = "hsl(54, 100%, 45%)", textColor = "hsl(40, 100%, 30%)", maxRotationX = 15, maxRotationY = 15, title = "PRODUCT HUNT", subtitle = "Maker of the Year 2024", }: BadgeProps) { const [isHovering, setIsHovering] = useState(false); const [target, setTarget] = useState<Point>({ x: 0, y: 0 }); const rotation = useAnimation(target, isHovering); const matrix = useMemo(() => createMatrix(rotation), [rotation]); const shineAngle = useMemo( () => Math.hypot(rotation.x, rotation.y) * 2, [rotation.x, rotation.y], ); const handleMouseMove = useMemo( () => (e: React.MouseEvent<HTMLAnchorElement>) => { if (!isHovering) return; const rect = e.currentTarget.getBoundingClientRect(); setTarget({ x: ((e.clientY - rect.top) / rect.height - 0.5) * 2 * maxRotationX, y: -((e.clientX - rect.left) / rect.width - 0.5) * 2 * maxRotationY, }); }, [isHovering, maxRotationX, maxRotationY], ); const handleMouseLeave = useMemo( () => () => { setIsHovering(false); setTarget({ x: 0, y: 0 }); }, [], ); return ( <a href={url} target="_blank" rel="noopener" className="block relative" onMouseMove={handleMouseMove} onMouseEnter={() => setIsHovering(true)} onMouseLeave={handleMouseLeave} > <div style={{ transform: `perspective(500px) matrix3d(${Object.values(matrix).join( ",", )})`, transformOrigin: "center center", }} > <svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${width} ${height}`} className="w-[180px] sm:w-[260px] h-auto" > <defs> <filter id="blur1"> <feGaussianBlur in="SourceGraphic" stdDeviation="3" /> </filter> <mask id="badgeMask"> <rect width={width} height={height} fill="white" rx="10" /> </mask> </defs> <rect width={width} height={height} rx="10" fill={bgColor} /> <rect x="4" y="4" width={width - 8} height={height - 8} rx="8" fill="transparent" stroke={borderColor} strokeWidth="1" /> <text fontFamily="Helvetica-Bold, Helvetica" fontSize="9" fontWeight="bold" fill={textColor} x="53" y="20" > {title} </text> <text fontFamily="Helvetica-Bold, Helvetica" fontSize="16" fontWeight="bold" fill={textColor} x="52" y="40" > {subtitle} </text> <AwardLogo color={textColor} /> <ShinyEffect width={width} height={height} shineAngle={shineAngle} /> </svg> </div> </a> ); }