|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useState, useEffect, useCallback, useRef } from 'react' |
| 4 | +import Image from 'next/image' |
| 5 | +import { motion } from 'framer-motion' |
| 6 | +import useEmblaCarousel from 'embla-carousel-react' |
| 7 | +import AutoHeight from 'embla-carousel-auto-height' |
| 8 | +import type { EmblaPluginType } from 'embla-carousel' |
| 9 | +import { |
| 10 | + PrevButton, |
| 11 | + NextButton, |
| 12 | + usePrevNextButtons |
| 13 | +} from '../gallery/embla/EmblaCarouselArrowButtons' |
| 14 | +import { useDotButton } from '../gallery/embla/EmblaCarouselDotButtons' |
| 15 | +import { cn } from '@/lib/utils' |
| 16 | +import testimonials from './testimonials.json' |
| 17 | +import '../../../app/embla.css' |
| 18 | + |
| 19 | +const CARD_BASE = |
| 20 | + 'rounded-2xl border-4 border-white/10 backdrop-blur-xs px-8 py-8 md:px-12 md:py-10 flex flex-col gap-6 transition-all duration-300 cursor-default' |
| 21 | +const CARD_ACTIVE = |
| 22 | + 'bg-light-mode/10 hover:scale-[1.010] hover:shadow-[0_0_26px_rgba(245,240,233,0.1)]' |
| 23 | +const CARD_INACTIVE = 'bg-light-mode/5 opacity-50' |
| 24 | + |
| 25 | +const KOI_STYLE = { |
| 26 | + bottom: '-23vw', |
| 27 | + x: '-200%', |
| 28 | + rotate: 80, |
| 29 | + height: '35vw', |
| 30 | + minHeight: '140px', |
| 31 | + minWidth: '140px', |
| 32 | + width: '15vw', |
| 33 | + maxHeight: '520px', |
| 34 | + maxWidth: '520px' |
| 35 | +} |
| 36 | + |
| 37 | +const Testimonials: React.FC = () => { |
| 38 | + const [plugins, setPlugins] = useState<EmblaPluginType[]>([]) |
| 39 | + |
| 40 | + useEffect(() => { |
| 41 | + if (window.matchMedia('(max-width: 767px)').matches) { |
| 42 | + setPlugins([AutoHeight()]) |
| 43 | + } |
| 44 | + }, []) |
| 45 | + |
| 46 | + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: 'center' }, plugins) |
| 47 | + const { selectedIndex } = useDotButton(emblaApi) |
| 48 | + const { prevBtnDisabled, nextBtnDisabled, onPrevButtonClick, onNextButtonClick } = |
| 49 | + usePrevNextButtons(emblaApi) |
| 50 | + |
| 51 | + // Track viewport height via ResizeObserver so nav buttons follow the |
| 52 | + // AutoHeight animation in real-time (top: 50% doesn't trigger CSS transitions) |
| 53 | + const viewportElRef = useRef<HTMLDivElement | null>(null) |
| 54 | + const [btnTop, setBtnTop] = useState<number | undefined>(undefined) |
| 55 | + |
| 56 | + const setViewportRef = useCallback( |
| 57 | + (node: HTMLDivElement | null) => { |
| 58 | + viewportElRef.current = node |
| 59 | + emblaRef(node) |
| 60 | + }, |
| 61 | + [emblaRef] |
| 62 | + ) |
| 63 | + |
| 64 | + useEffect(() => { |
| 65 | + const el = viewportElRef.current |
| 66 | + if (!el) return |
| 67 | + setBtnTop(el.offsetHeight / 2) |
| 68 | + const ro = new ResizeObserver(() => setBtnTop(el.offsetHeight / 2)) |
| 69 | + ro.observe(el) |
| 70 | + return () => ro.disconnect() |
| 71 | + }, [emblaApi]) |
| 72 | + |
| 73 | + return ( |
| 74 | + <div className='relative mt-[25vh] md:mt-[35vh]'> |
| 75 | + <div |
| 76 | + id='Testimonials' |
| 77 | + className='max-w-[1800px] w-full flex flex-col items-center mx-auto' |
| 78 | + > |
| 79 | + <div className='w-full flex justify-center text-center mb-10'> |
| 80 | + <h2 className='text-3xl md:text-5xl xl:text-6xl font-bold pb-4 border-b-1 text-light-mode'> |
| 81 | + Why Join The Team |
| 82 | + </h2> |
| 83 | + </div> |
| 84 | + |
| 85 | + <div className='relative w-full md:max-w-[75vw]'> |
| 86 | + {/* edge fade overlays */} |
| 87 | + <div className='absolute inset-y-0 left-0 w-[18%] z-10 pointer-events-none bg-gradient-to-r from-sea to-transparent' /> |
| 88 | + <div className='absolute inset-y-0 right-0 w-[18%] z-10 pointer-events-none bg-gradient-to-l from-sea to-transparent' /> |
| 89 | + |
| 90 | + {/* nav buttons — top set via JS so they track the AutoHeight animation */} |
| 91 | + <div |
| 92 | + className='absolute left-2 -translate-y-1/2 z-20 rounded-full bg-sea/70 backdrop-blur-md' |
| 93 | + style={{ top: btnTop ?? '50%' }} |
| 94 | + > |
| 95 | + <PrevButton onClick={onPrevButtonClick} disabled={prevBtnDisabled} /> |
| 96 | + </div> |
| 97 | + <div |
| 98 | + className='absolute right-2 -translate-y-1/2 z-20 rounded-full bg-sea/70 backdrop-blur-md' |
| 99 | + style={{ top: btnTop ?? '50%' }} |
| 100 | + > |
| 101 | + <NextButton onClick={onNextButtonClick} disabled={nextBtnDisabled} /> |
| 102 | + </div> |
| 103 | + |
| 104 | + <div |
| 105 | + className='overflow-hidden py-5 transition-[height] duration-300 ease-in-out' |
| 106 | + ref={setViewportRef} |
| 107 | + > |
| 108 | + <div className='flex items-center [touch-action:pan-y_pinch-zoom] -ml-6'> |
| 109 | + {testimonials.map((item, idx) => ( |
| 110 | + <div key={idx} className='flex-[0_0_85%] md:flex-[0_0_70%] min-w-0 pl-6'> |
| 111 | + <div className={cn(CARD_BASE, idx === selectedIndex ? CARD_ACTIVE : CARD_INACTIVE)}> |
| 112 | + <p className='text-sm md:text-base leading-[1.6] text-light-mode'> |
| 113 | + {item.quote} |
| 114 | + </p> |
| 115 | + <div className='flex flex-col gap-1'> |
| 116 | + <span className='text-sm font-medium text-light-mode/60'>{item.name}</span> |
| 117 | + {item.title && ( |
| 118 | + <span className='text-xs text-light-mode/40 whitespace-pre-line'> |
| 119 | + {item.title} |
| 120 | + </span> |
| 121 | + )} |
| 122 | + </div> |
| 123 | + </div> |
| 124 | + </div> |
| 125 | + ))} |
| 126 | + </div> |
| 127 | + </div> |
| 128 | + </div> |
| 129 | + </div> |
| 130 | + |
| 131 | + {/* decorative koi for desktop only */} |
| 132 | + <motion.div |
| 133 | + className='hidden md:block absolute left-1/2 z-10' |
| 134 | + style={KOI_STYLE} |
| 135 | + animate={{ y: ['0vw', '-0.8vw', '0vw'] }} |
| 136 | + transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }} |
| 137 | + > |
| 138 | + <div className='relative w-full h-full'> |
| 139 | + <Image src='/images/koi2.svg' alt='Koi' fill className='object-contain opacity-80' /> |
| 140 | + </div> |
| 141 | + </motion.div> |
| 142 | + </div> |
| 143 | + ) |
| 144 | +} |
| 145 | + |
| 146 | +export default Testimonials |
0 commit comments