1+ "use client" ;
2+
3+ import Image from "next/image" ;
4+ import Link from "next/link" ;
5+ import React , { useEffect , useMemo , useRef , useState } from "react" ;
6+
7+ export type PressReviewCard = {
8+ id : string ;
9+ title : string ;
10+ source : string ;
11+ url : string ;
12+ imageUrl ?: string ;
13+ tag ?: string ;
14+ date ?: string ;
15+ } ;
16+
17+ type PressReviewSectionProps = {
18+ rows : [ PressReviewCard [ ] , PressReviewCard [ ] ] ;
19+ className ?: string ;
20+ } ;
21+
22+ type MarqueeRowProps = {
23+ items : PressReviewCard [ ] ;
24+ direction ?: "left" | "right" ;
25+ baseSpeed ?: number ;
26+ pauseOnHover ?: boolean ;
27+ } ;
28+
29+ function cn ( ...classes : Array < string | false | null | undefined > ) {
30+ return classes . filter ( Boolean ) . join ( " " ) ;
31+ }
32+
33+ function getHostname ( url : string ) {
34+ try {
35+ return new URL ( url ) . hostname . replace ( "www." , "" ) ;
36+ } catch {
37+ return url ;
38+ }
39+ }
40+
41+ function isPdfUrl ( url : string ) {
42+ return / \. p d f ( \? | # | $ ) / i. test ( url ) ;
43+ }
44+
45+ function getAutoPreviewImage ( url : string ) {
46+ return `https://image.thum.io/get/width/1200/crop/800/noanimate/${ encodeURIComponent ( url ) } ` ;
47+ }
48+
49+ function PressCard ( { item } : { item : PressReviewCard } ) {
50+ const [ previewFailed , setPreviewFailed ] = useState ( false ) ;
51+
52+ const hasCustomImage = Boolean ( item . imageUrl ?. trim ( ) ) ;
53+ const pdf = isPdfUrl ( item . url ) ;
54+ const domain = getHostname ( item . url ) ;
55+ const showRemotePreview = ! hasCustomImage && ! pdf && ! previewFailed ;
56+
57+ return (
58+ < Link
59+ href = { item . url }
60+ target = "_blank"
61+ rel = "noopener noreferrer"
62+ className = { cn (
63+ "group relative shrink-0" ,
64+ "w-90 max-[900px]:w-75 max-[640px]:w-67.5" ,
65+ "h-60 max-[900px]:h-55" ,
66+ "overflow-hidden rounded-[28px]" ,
67+ "border border-white/10 bg-[#101010]" ,
68+ "shadow-[0_8px_30px_rgba(0,0,0,0.28)]" ,
69+ "transition-all duration-400 ease-out" ,
70+ "hover:-translate-y-1.5 hover:border-[#9EF0A8]/30 hover:shadow-[0_18px_60px_rgba(0,0,0,0.38)]"
71+ ) }
72+ >
73+ < div className = "absolute inset-0" >
74+ { hasCustomImage ? (
75+ < Image
76+ src = { item . imageUrl ! }
77+ alt = { item . title }
78+ fill
79+ className = "object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
80+ sizes = "(max-width: 640px) 270px, (max-width: 900px) 300px, 360px"
81+ />
82+ ) : showRemotePreview ? (
83+ < img
84+ src = { getAutoPreviewImage ( item . url ) }
85+ alt = { item . title }
86+ className = "h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
87+ onError = { ( ) => setPreviewFailed ( true ) }
88+ />
89+ ) : (
90+ < div className = "relative h-full w-full overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(170,255,176,0.16),transparent_35%),linear-gradient(135deg,#161616_0%,#111111_45%,#0b0b0b_100%)]" >
91+ < div className = "absolute -right-10 -top-10 h-40 w-40 rounded-full bg-[#9EF0A8]/10 blur-3xl" />
92+
93+ < div className = "absolute inset-0 flex flex-col justify-between p-6" >
94+ < div className = "flex items-center justify-between gap-3" >
95+ < div className = "rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70" >
96+ { pdf ? "PDF" : "Preview" }
97+ </ div >
98+
99+ < div className = "rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-white/60 backdrop-blur-md" >
100+ { domain }
101+ </ div >
102+ </ div >
103+
104+ < div >
105+ < div className = "mb-3 line-clamp-2 text-[15px] font-semibold text-white/90" >
106+ { item . title }
107+ </ div >
108+ < div className = "line-clamp-2 break-all text-[13px] text-white/55" >
109+ { item . url }
110+ </ div >
111+ </ div >
112+ </ div >
113+ </ div >
114+ ) }
115+
116+ < div className = "absolute inset-0 bg-linear-to-t from-black via-black/40 to-black/10" />
117+ < div className = "absolute inset-0 opacity-0 transition-opacity duration-400 group-hover:opacity-100 bg-[linear-gradient(135deg,rgba(158,240,168,0.10),transparent_55%,rgba(255,255,255,0.04))]" />
118+ </ div >
119+
120+ < div className = "absolute inset-x-0 bottom-0 z-10 p-5" >
121+ < div className = "mb-3 flex items-center gap-2" >
122+ { item . tag ? (
123+ < span className = "rounded-full border border-[#9EF0A8]/20 bg-[#9EF0A8]/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-[#D6FFDB]" >
124+ { item . tag }
125+ </ span >
126+ ) : null }
127+
128+ { item . date ? (
129+ < span className = "rounded-full border border-white/10 bg-white/6 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.12em] text-white/70 backdrop-blur-md" >
130+ { item . date }
131+ </ span >
132+ ) : null }
133+ </ div >
134+
135+ < div className = "mb-1 line-clamp-2 text-[20px] font-black leading-tight text-white transition-transform duration-400 group-hover:translate-x-0.5" >
136+ { item . title }
137+ </ div >
138+
139+ < div className = "flex items-center justify-between gap-4" >
140+ < div className = "text-[13px] text-white/65" > { item . source } </ div >
141+
142+ < div className = "flex items-center gap-2 text-[13px] font-medium text-[#C9FFD0]" >
143+ < span > Apri</ span >
144+ < svg
145+ className = "transition-transform duration-300 group-hover:-translate-y-1 group-hover:translate-x-1"
146+ width = "16"
147+ height = "16"
148+ viewBox = "0 0 24 24"
149+ fill = "none"
150+ >
151+ < path
152+ d = "M7 17L17 7M17 7H9M17 7V15"
153+ stroke = "currentColor"
154+ strokeWidth = "2"
155+ strokeLinecap = "round"
156+ strokeLinejoin = "round"
157+ />
158+ </ svg >
159+ </ div >
160+ </ div >
161+ </ div >
162+
163+ < div className = "pointer-events-none absolute inset-0 rounded-[28px] ring-1 ring-inset ring-white/6" />
164+ </ Link >
165+ ) ;
166+ }
167+
168+ function MarqueeRow ( {
169+ items,
170+ direction = "left" ,
171+ baseSpeed = 0.45 ,
172+ pauseOnHover = true
173+ } : MarqueeRowProps ) {
174+ const trackRef = useRef < HTMLDivElement | null > ( null ) ;
175+
176+ const duplicatedItems = useMemo ( ( ) => {
177+ if ( items . length === 0 ) return [ ] ;
178+ return [ ...items , ...items , ...items , ...items ] ;
179+ } , [ items ] ) ;
180+
181+ const offsetRef = useRef ( 0 ) ;
182+ const animationFrameRef = useRef < number | null > ( null ) ;
183+ const hoveredRef = useRef ( false ) ;
184+ const draggingRef = useRef ( false ) ;
185+ const boostRef = useRef ( 0 ) ;
186+ const lastXRef = useRef ( 0 ) ;
187+ const loopWidthRef = useRef ( 0 ) ;
188+
189+ useEffect ( ( ) => {
190+ const handleMouseUp = ( ) => {
191+ draggingRef . current = false ;
192+ } ;
193+
194+ window . addEventListener ( "mouseup" , handleMouseUp ) ;
195+
196+ return ( ) => {
197+ window . removeEventListener ( "mouseup" , handleMouseUp ) ;
198+ } ;
199+ } , [ ] ) ;
200+
201+ useEffect ( ( ) => {
202+ const track = trackRef . current ;
203+ if ( ! track ) return ;
204+
205+ const updateLoopWidth = ( ) => {
206+ loopWidthRef . current = track . scrollWidth / 4 ;
207+ } ;
208+
209+ updateLoopWidth ( ) ;
210+
211+ const tick = ( ) => {
212+ const loopWidth = loopWidthRef . current ;
213+
214+ if ( ! loopWidth ) {
215+ animationFrameRef . current = requestAnimationFrame ( tick ) ;
216+ return ;
217+ }
218+
219+ const directionMultiplier = direction === "left" ? 1 : - 1 ;
220+ const paused = pauseOnHover && hoveredRef . current && ! draggingRef . current ;
221+
222+ if ( ! paused ) {
223+ const speed = baseSpeed + boostRef . current ;
224+ offsetRef . current += speed * directionMultiplier ;
225+
226+ if ( offsetRef . current >= loopWidth ) {
227+ offsetRef . current -= loopWidth ;
228+ } else if ( offsetRef . current < 0 ) {
229+ offsetRef . current += loopWidth ;
230+ }
231+
232+ boostRef . current *= 0.94 ;
233+ if ( Math . abs ( boostRef . current ) < 0.01 ) {
234+ boostRef . current = 0 ;
235+ }
236+ }
237+
238+ track . style . transform = `translate3d(${ - offsetRef . current } px, 0, 0)` ;
239+ animationFrameRef . current = requestAnimationFrame ( tick ) ;
240+ } ;
241+
242+ animationFrameRef . current = requestAnimationFrame ( tick ) ;
243+
244+ const resizeObserver = new ResizeObserver ( ( ) => {
245+ updateLoopWidth ( ) ;
246+ } ) ;
247+
248+ resizeObserver . observe ( track ) ;
249+
250+ return ( ) => {
251+ resizeObserver . disconnect ( ) ;
252+ if ( animationFrameRef . current ) {
253+ cancelAnimationFrame ( animationFrameRef . current ) ;
254+ }
255+ } ;
256+ } , [ baseSpeed , direction , pauseOnHover , duplicatedItems ] ) ;
257+
258+ const onWheel = ( e : React . WheelEvent < HTMLDivElement > ) => {
259+ const delta = Math . abs ( e . deltaY ) + Math . abs ( e . deltaX ) ;
260+ boostRef . current = Math . min ( 4.5 , boostRef . current + delta * 0.0045 ) ;
261+ } ;
262+
263+ const onMouseDown = ( e : React . MouseEvent < HTMLDivElement > ) => {
264+ draggingRef . current = true ;
265+ lastXRef . current = e . clientX ;
266+ } ;
267+
268+ const onMouseMove = ( e : React . MouseEvent < HTMLDivElement > ) => {
269+ if ( ! draggingRef . current ) return ;
270+
271+ const deltaX = e . clientX - lastXRef . current ;
272+ lastXRef . current = e . clientX ;
273+
274+ offsetRef . current -= deltaX ;
275+
276+ const loopWidth = loopWidthRef . current ;
277+ if ( loopWidth > 0 ) {
278+ while ( offsetRef . current < 0 ) {
279+ offsetRef . current += loopWidth ;
280+ }
281+ while ( offsetRef . current >= loopWidth ) {
282+ offsetRef . current -= loopWidth ;
283+ }
284+ }
285+
286+ if ( trackRef . current ) {
287+ trackRef . current . style . transform = `translate3d(${ - offsetRef . current } px, 0, 0)` ;
288+ }
289+ } ;
290+
291+ const stopDragging = ( ) => {
292+ draggingRef . current = false ;
293+ } ;
294+
295+ return (
296+ < div
297+ className = { cn (
298+ "group relative overflow-hidden" ,
299+ "before:pointer-events-none before:absolute before:left-0 before:top-0 before:z-20 before:h-full before:w-24 before:bg-linear-to-r before:from-[#0a0a0a] before:to-transparent" ,
300+ "after:pointer-events-none after:absolute after:right-0 after:top-0 after:z-20 after:h-full after:w-24 after:bg-linear-to-l after:from-[#0a0a0a] after:to-transparent"
301+ ) }
302+ onMouseEnter = { ( ) => {
303+ hoveredRef . current = true ;
304+ } }
305+ onMouseLeave = { ( ) => {
306+ hoveredRef . current = false ;
307+ draggingRef . current = false ;
308+ } }
309+ onWheel = { onWheel }
310+ onMouseDown = { onMouseDown }
311+ onMouseMove = { onMouseMove }
312+ onMouseUp = { stopDragging }
313+ >
314+ < div
315+ ref = { trackRef }
316+ className = "flex w-max gap-5 py-3 select-none"
317+ style = { {
318+ willChange : "transform" ,
319+ cursor : draggingRef . current ? "grabbing" : "grab"
320+ } }
321+ >
322+ { duplicatedItems . map ( ( item , index ) => (
323+ < PressCard key = { `${ item . id } -${ index } ` } item = { item } />
324+ ) ) }
325+ </ div >
326+ </ div >
327+ ) ;
328+ }
329+
330+ export function PressReviewSection ( { rows, className } : PressReviewSectionProps ) {
331+ return (
332+ < section className = { cn ( "relative w-full overflow-hidden py-26" , className ) } >
333+ < div className = "mx-auto w-full max-w-375" >
334+ < div className = "mb-10 flex flex-col items-center px-6" >
335+ < div className = "mb-3 rounded-full border border-[#9EF0A8]/18 bg-[#9EF0A8]/8 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#D6FFDB]" >
336+ Rassegna stampa
337+ </ div >
338+
339+ < h2 className = "text-center text-[64px] font-black leading-none max-[900px]:text-[42px]" >
340+ Ci raccontano
341+ </ h2 >
342+
343+ < p className = "mt-4 mb-6 max-w-190 text-center text-[18px] text-white/65 max-[900px]:text-[16px]" >
344+ Una selezione di articoli, approfondimenti e pubblicazioni che provano della necessità e della veridicità del nostro progetto.
345+ </ p >
346+ </ div >
347+
348+ < div className = "space-y-4" >
349+ < MarqueeRow items = { rows [ 0 ] } direction = "left" baseSpeed = { 0.48 } />
350+ < MarqueeRow items = { rows [ 1 ] } direction = "right" baseSpeed = { 0.42 } />
351+ </ div >
352+ </ div >
353+ </ section >
354+ ) ;
355+ }
0 commit comments