2025-10-09 15:39:46 +09:00
|
|
|
"use client";
|
2025-10-13 18:06:46 +09:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import Image from "next/image";
|
2025-11-01 23:16:22 +09:00
|
|
|
import { SelectedBanner } from "@/app/components/SelectedBanner";
|
2025-10-09 15:39:46 +09:00
|
|
|
|
2025-10-09 18:42:50 +09:00
|
|
|
type Banner = { id: string; title: string; imageUrl: string; linkUrl?: string | null };
|
2025-11-01 23:16:22 +09:00
|
|
|
type SubItem = { id: string; name: string; href: string };
|
2025-10-09 15:39:46 +09:00
|
|
|
|
2025-11-01 23:16:22 +09:00
|
|
|
export function HeroBanner({ subItems, activeSubId }: { subItems?: SubItem[]; activeSubId?: string }) {
|
2025-10-09 18:42:50 +09:00
|
|
|
const [banners, setBanners] = useState<Banner[]>([]);
|
2025-10-13 18:06:46 +09:00
|
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
|
const [progress, setProgress] = useState(0); // 0..1
|
|
|
|
|
const rotationMs = 5000;
|
|
|
|
|
const rafIdRef = useRef<number | null>(null);
|
|
|
|
|
const startedAtRef = useRef<number>(0);
|
|
|
|
|
|
2025-10-09 15:39:46 +09:00
|
|
|
useEffect(() => {
|
2025-10-09 18:42:50 +09:00
|
|
|
fetch("/api/banners").then((r) => r.json()).then((d) => setBanners(d.banners ?? []));
|
2025-10-09 15:39:46 +09:00
|
|
|
}, []);
|
2025-10-13 18:06:46 +09:00
|
|
|
|
|
|
|
|
const numSlides = banners.length;
|
|
|
|
|
const canAutoPlay = numSlides > 1 && !isHovered;
|
|
|
|
|
|
|
|
|
|
const startTicker = useCallback(() => {
|
|
|
|
|
if (!canAutoPlay) return;
|
|
|
|
|
startedAtRef.current = performance.now();
|
|
|
|
|
const tick = () => {
|
|
|
|
|
const now = performance.now();
|
|
|
|
|
const elapsed = now - startedAtRef.current;
|
|
|
|
|
const nextProgress = Math.min(1, elapsed / rotationMs);
|
|
|
|
|
setProgress(nextProgress);
|
|
|
|
|
if (nextProgress >= 1) {
|
|
|
|
|
setActiveIndex((i) => (i + 1) % Math.max(1, numSlides));
|
|
|
|
|
startedAtRef.current = performance.now();
|
|
|
|
|
setProgress(0);
|
|
|
|
|
}
|
|
|
|
|
rafIdRef.current = window.requestAnimationFrame(tick);
|
|
|
|
|
};
|
|
|
|
|
rafIdRef.current = window.requestAnimationFrame(tick);
|
|
|
|
|
}, [canAutoPlay, numSlides, rotationMs]);
|
|
|
|
|
|
|
|
|
|
const stopTicker = useCallback(() => {
|
|
|
|
|
if (rafIdRef.current) {
|
|
|
|
|
window.cancelAnimationFrame(rafIdRef.current);
|
|
|
|
|
rafIdRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-10-09 18:42:50 +09:00
|
|
|
useEffect(() => {
|
2025-10-13 18:06:46 +09:00
|
|
|
stopTicker();
|
|
|
|
|
setProgress(0);
|
|
|
|
|
startTicker();
|
|
|
|
|
return () => stopTicker();
|
|
|
|
|
}, [startTicker, stopTicker, activeIndex, isHovered, numSlides]);
|
|
|
|
|
|
|
|
|
|
const goTo = useCallback((index: number) => {
|
|
|
|
|
if (numSlides === 0) return;
|
|
|
|
|
const next = (index + numSlides) % numSlides;
|
|
|
|
|
setActiveIndex(next);
|
|
|
|
|
setProgress(0);
|
|
|
|
|
startedAtRef.current = performance.now();
|
|
|
|
|
}, [numSlides]);
|
|
|
|
|
|
|
|
|
|
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
|
|
|
|
|
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
|
|
|
|
|
|
|
|
|
|
const translatePercent = useMemo(() => (numSlides > 0 ? -(activeIndex * 100) : 0), [activeIndex, numSlides]);
|
|
|
|
|
|
2025-11-01 23:16:22 +09:00
|
|
|
if (numSlides === 0) return <SelectedBanner height={224} />;
|
2025-10-13 18:06:46 +09:00
|
|
|
|
2025-10-09 18:42:50 +09:00
|
|
|
return (
|
2025-10-13 18:06:46 +09:00
|
|
|
<section
|
|
|
|
|
className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white"
|
|
|
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
|
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
|
|
|
aria-roledescription="carousel"
|
|
|
|
|
>
|
2025-10-24 21:24:51 +09:00
|
|
|
<div className="relative h-56 sm:h-72 md:h-[264px]">
|
2025-10-13 18:06:46 +09:00
|
|
|
<div
|
|
|
|
|
className="flex h-full w-full transition-transform duration-500 ease-out"
|
|
|
|
|
style={{ transform: `translate3d(${translatePercent}%, 0, 0)`, width: `${numSlides * 100}%` }}
|
|
|
|
|
>
|
|
|
|
|
{banners.map((banner) => (
|
|
|
|
|
<div key={banner.id} className="relative h-full w-full shrink-0">
|
|
|
|
|
{banner.linkUrl ? (
|
|
|
|
|
<a href={banner.linkUrl} className="block h-full w-full focus:outline-none focus:ring-2 focus:ring-white/60" aria-label={banner.title}>
|
|
|
|
|
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
|
|
|
|
<Image src={banner.imageUrl} alt={banner.title} fill priority className="object-cover" />
|
|
|
|
|
)}
|
2025-11-01 23:16:22 +09:00
|
|
|
{/* Figma 스타일: 오버레이 제거 */}
|
2025-10-13 18:06:46 +09:00
|
|
|
<div className="absolute bottom-3 left-4 right-4 md:bottom-5 md:left-6 md:right-6">
|
|
|
|
|
<h2 className="line-clamp-2 text-lg font-semibold md:text-2xl">{banner.title}</h2>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-01 23:16:22 +09:00
|
|
|
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
|
2025-10-13 18:06:46 +09:00
|
|
|
{numSlides > 1 && (
|
2025-11-01 23:16:22 +09:00
|
|
|
<div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[6px]">
|
|
|
|
|
{banners.map((_, i) => (
|
|
|
|
|
<button
|
|
|
|
|
key={i}
|
|
|
|
|
aria-label={`Go to slide ${i + 1}`}
|
|
|
|
|
aria-current={activeIndex === i ? "true" : undefined}
|
|
|
|
|
onClick={() => goTo(i)}
|
|
|
|
|
className={
|
|
|
|
|
activeIndex === i
|
|
|
|
|
? "h-[4px] w-[18px] rounded-full bg-[#F94B37]"
|
|
|
|
|
: "h-[6px] w-[6px] rounded-full bg-[rgba(255,255,255,0.6)] hover:bg-white"
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
|
|
|
|
|
<div className="mt-2 h-[58px] bg-black rounded-xl flex items-center justify-center px-2">
|
|
|
|
|
{Array.isArray(subItems) && subItems.length > 0 && (
|
|
|
|
|
<div className="flex items-center gap-[8px] overflow-x-auto no-scrollbar">
|
|
|
|
|
{subItems.map((s) => (
|
|
|
|
|
<a
|
|
|
|
|
key={s.id}
|
|
|
|
|
href={s.href}
|
|
|
|
|
className={
|
|
|
|
|
s.id === activeSubId
|
|
|
|
|
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
|
|
|
|
|
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:text-white border border-white/30 text-[12px] leading-[28px] whitespace-nowrap"
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{s.name}
|
|
|
|
|
</a>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2025-10-13 18:06:46 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Progress bar
|
|
|
|
|
{numSlides > 1 && (
|
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 z-10 h-1 bg-white/20">
|
|
|
|
|
<div className="h-full bg-white transition-[width] duration-100" style={{ width: `${progress * 100}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
)} */}
|
|
|
|
|
|
2025-11-01 23:16:22 +09:00
|
|
|
|
2025-10-09 15:39:46 +09:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|