Files
msgapp/src/app/components/HeroBanner.tsx

213 lines
8.9 KiB
TypeScript
Raw Normal View History

"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-11-02 02:46:20 +09:00
import Link from "next/link";
import useSWR from "swr";
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-11-02 02:46:20 +09:00
const fetcher = (url: string) => fetch(url).then((r) => r.json());
2025-11-02 15:13:03 +09:00
export function HeroBanner({ subItems, activeSubId, hideSubOnMobile }: { subItems?: SubItem[]; activeSubId?: string; hideSubOnMobile?: boolean }) {
2025-11-02 02:46:20 +09:00
// SWR 캐시 사용: 페이지 전환 시 재요청/깜빡임 최소화
const { data } = useSWR<{ banners: Banner[] }>("/api/banners", fetcher, {
revalidateOnFocus: false,
dedupingInterval: 10 * 60 * 1000, // 10분 간 동일 요청 합치기
keepPreviousData: true,
});
const banners = data?.banners ?? [];
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);
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;
}
}, []);
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-02 02:46:20 +09:00
if (numSlides === 0) {
return (
<section className="relative w-full overflow-hidden rounded-xl bg-neutral-900 text-white" aria-roledescription="carousel">
<SelectedBanner className="h-56 sm:h-72 md:h-[264px]" />
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 (스켈레톤 상태에서도 동일 레이아웃 유지) */}
2025-11-02 15:13:03 +09:00
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
2025-11-02 02:46:20 +09:00
{Array.isArray(subItems) && subItems.length > 0 && (
<div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => (
<Link
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"
2025-11-05 21:07:49 +09:00
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
2025-11-02 02:46:20 +09:00
}
>
{s.name}
</Link>
))}
</div>
)}
</div>
</section>
);
}
2025-10-13 18:06:46 +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-05 23:03:53 +09:00
{/* 좌우 내비게이션 버튼 */}
{numSlides > 1 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-1 sm:px-2">
<button
type="button"
onClick={goPrev}
aria-label="이전 배너"
className="pointer-events-auto inline-flex items-center justify-center h-8 w-8 text-white/80 hover:text-[var(--red-50,#F94B37)] transition-colors focus:outline-none"
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<button
type="button"
onClick={goNext}
aria-label="다음 배너"
className="pointer-events-auto inline-flex items-center justify-center h-8 w-8 text-white/80 hover:text-[var(--red-50,#F94B37)] transition-colors focus:outline-none"
>
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
)}
2025-11-01 23:16:22 +09:00
{/* Pagination - Figma 스타일: 활성은 주황 바, 비활성은 회색 점 (배너 위에 오버랩) */}
2025-10-13 18:06:46 +09:00
{numSlides > 1 && (
2025-11-02 04:59:09 +09:00
<div className="pointer-events-auto absolute bottom-3 left-1/2 -translate-x-1/2 z-10 flex items-center gap-[10px]">
2025-11-01 23:16:22 +09:00
{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
2025-11-02 04:59:09 +09:00
? "h-[6px] w-[24px] rounded-full bg-[#F94B37]"
: "h-[10px] w-[10px] rounded-full bg-[rgba(255,255,255,0.7)] hover:bg-white"
2025-11-01 23:16:22 +09:00
}
/>
))}
</div>
)}
</div>
{/* 분리된 하단 블랙 바: 높이 58px, 중앙 서브카테고리 */}
2025-11-02 15:13:03 +09:00
<div className={`${hideSubOnMobile ? "hidden md:flex" : "flex"} h-[58px] bg-black rounded-xl items-center justify-center px-2`}>
2025-11-01 23:16:22 +09:00
{Array.isArray(subItems) && subItems.length > 0 && (
2025-11-02 02:46:20 +09:00
<div className="flex flex-wrap items-center gap-[8px]">
{subItems.map((s) => (
<Link
2025-11-01 23:16:22 +09:00
key={s.id}
2025-11-02 02:46:20 +09:00
href={s.href}
2025-11-01 23:16:22 +09:00
className={
s.id === activeSubId
? "px-3 h-[28px] rounded-full bg-[#F94B37] text-white text-[12px] leading-[28px] whitespace-nowrap"
2025-11-05 21:07:49 +09:00
: "px-3 h-[28px] rounded-full bg-transparent text-white/85 hover:bg-[#F94B37] hover:text-white text-[12px] leading-[28px] whitespace-nowrap"
2025-11-01 23:16:22 +09:00
}
>
{s.name}
2025-11-02 02:46:20 +09:00
</Link>
2025-11-01 23:16:22 +09:00
))}
</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
</section>
);
}