"use client"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import "./partner-ribbon.css"; export type Partner = { id: string; name: string; imageSrc?: string; size?: number }; export type PartnerRibbonProps = { items: Partner[]; label?: string; direction?: "left" | "right"; /** Pixels per second; omit for 28 desktop / 20 mobile. Zero holds still. */ speed?: number; rows?: 1 | 2; treatment?: "muted" | "original"; className?: string; }; function PartnerMark({ item }: { item: Partner }) { const [failed,setFailed]=useState(false); useEffect(()=>setFailed(false),[item.imageSrc]); const scale=Number.isFinite(item.size)?Math.max(.5,Math.min(2,item.size!)):1; return {item.imageSrc&&!failed?{item.name}setFailed(true)} draggable={false}/>:{item.name}}; } /** A measured group is the exact repeat period, including its trailing gap. */ function RibbonTrack({ items, direction, speed, paused, staticMode }: { items:Partner[]; direction:"left"|"right"; speed?:number; paused:boolean; staticMode:boolean }) { const viewport=useRef(null), track=useRef(null), group=useRef(null); const distance=useRef(0), offset=useRef(0), hover=useRef(false), visible=useRef(false); const [copies,setCopies]=useState(2); const speedValue=Number.isFinite(speed)?Math.max(0,speed!):undefined; useEffect(()=>{ if(staticMode)return; const box=viewport.current,first=group.current;if(!box||!first)return; const measure=()=>{ const next=first.getBoundingClientRect().width; if(next<=0)return; if(distance.current>0)offset.current=offset.current/distance.current*next; distance.current=next; if(track.current)track.current.style.transform=`translate3d(${direction==="left"?-offset.current:offset.current-next}px,0,0)`; setCopies(Math.max(2,Math.ceil(box.getBoundingClientRect().width/next)+2)); }; measure();const ro=new ResizeObserver(measure);ro.observe(box);ro.observe(first); const fonts=document.fonts;fonts?.addEventListener("loadingdone",measure); let active=true;fonts?.ready.then(()=>{if(active)measure();}); first.addEventListener("load",measure,true);first.addEventListener("error",measure,true); return()=>{active=false;ro.disconnect();fonts?.removeEventListener("loadingdone",measure);first.removeEventListener("load",measure,true);first.removeEventListener("error",measure,true);}; },[items,staticMode,direction]); useEffect(()=>{ if(staticMode||paused)return; const box=viewport.current;if(!box)return; hover.current=box.matches(":hover"); let frame=0,last=0,disposed=false; const apply=()=>{if(track.current){const d=distance.current;const x=direction==="left"?-offset.current:offset.current-d;track.current.style.transform=`translate3d(${x}px,0,0)`;}}; const stop=()=>{cancelAnimationFrame(frame);frame=0;last=0;}; const tick=(now:number)=>{ frame=0;if(disposed||document.hidden||!visible.current||hover.current)return; if(last&&distance.current>0){const elapsed=Math.min((now-last)/1000,.064);const px=speedValue??(box.clientWidth<640?20:28);offset.current=(offset.current+elapsed*px)%distance.current;} last=now;apply();frame=requestAnimationFrame(tick); }; const start=()=>{if(!disposed&&!document.hidden&&visible.current&&!hover.current&&speedValue!==0&&!frame)frame=requestAnimationFrame(tick);}; const visibility=()=>{stop();start();}; const enter=()=>{hover.current=true;stop();};const leave=()=>{hover.current=false;start();}; const io=new IntersectionObserver(([entry])=>{visible.current=entry.isIntersecting;stop();start();});io.observe(box); document.addEventListener("visibilitychange",visibility);box.addEventListener("pointerenter",enter);box.addEventListener("pointerleave",leave);apply(); return()=>{disposed=true;stop();io.disconnect();document.removeEventListener("visibilitychange",visibility);box.removeEventListener("pointerenter",enter);box.removeEventListener("pointerleave",leave);hover.current=false;}; },[direction,paused,speedValue,staticMode,items]); if(staticMode)return {items.map(item=>)}; return ; } export function PartnerRibbon({ items,label="In good company",direction="left",speed,rows=1,treatment="muted",className="" }:PartnerRibbonProps) { const [paused,setPaused]=useState(false),[reduced,setReduced]=useState(true); useEffect(()=>{const query=window.matchMedia("(prefers-reduced-motion: reduce)");const update=()=>setReduced(query.matches);update();query.addEventListener("change",update);return()=>query.removeEventListener("change",update);},[]); if(!items.length)return null; // Separate, stable sets prevent repeated identities in the two-row treatment. const sets=rows===2&&items.length>1?[items.filter((_,i)=>i%2===0),items.filter((_,i)=>i%2===1)]:[items]; const staticMode=reduced||sets.every(set=>set.length<=1); return

{label}

{staticMode ? {sets.map((set,i)=>)} : } {!staticMode&&
    {items.map(item=>
  • {item.name}
  • )}
}
; }