diff --git a/src/frontend/public/media/hero/hero-1.jpg b/src/frontend/public/media/hero/hero-1.jpg new file mode 100644 index 0000000..025dfd9 Binary files /dev/null and b/src/frontend/public/media/hero/hero-1.jpg differ diff --git a/src/frontend/public/media/hero/hero-2.jpg b/src/frontend/public/media/hero/hero-2.jpg new file mode 100644 index 0000000..15df488 Binary files /dev/null and b/src/frontend/public/media/hero/hero-2.jpg differ diff --git a/src/frontend/public/media/hero/hero-3.jpg b/src/frontend/public/media/hero/hero-3.jpg new file mode 100644 index 0000000..df1a809 Binary files /dev/null and b/src/frontend/public/media/hero/hero-3.jpg differ diff --git a/src/frontend/public/media/hero/hero-4.jpg b/src/frontend/public/media/hero/hero-4.jpg new file mode 100644 index 0000000..0157b22 Binary files /dev/null and b/src/frontend/public/media/hero/hero-4.jpg differ diff --git a/src/frontend/public/media/hero/hero-5.jpg b/src/frontend/public/media/hero/hero-5.jpg new file mode 100644 index 0000000..f6429ed Binary files /dev/null and b/src/frontend/public/media/hero/hero-5.jpg differ diff --git a/src/frontend/src/screens/public/AgencyTrackPage.tsx b/src/frontend/src/screens/public/AgencyTrackPage.tsx index e273f2f..c0f6d7d 100644 --- a/src/frontend/src/screens/public/AgencyTrackPage.tsx +++ b/src/frontend/src/screens/public/AgencyTrackPage.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { PublicShell } from './PublicShell'; +import { PublicHeroSlides } from './PublicHeroSlides'; import { IconFactory, IconShield, @@ -33,7 +34,9 @@ export function AgencyTrackPage() { className="kxp-hero__bg" style={{ background: 'linear-gradient(135deg, #1a1330 0%, #3a2a6b 55%, #0066b3 130%)' }} aria-hidden - /> + > + +
diff --git a/src/frontend/src/screens/public/BusinessTrackPage.tsx b/src/frontend/src/screens/public/BusinessTrackPage.tsx index 3e152ba..c5e1056 100644 --- a/src/frontend/src/screens/public/BusinessTrackPage.tsx +++ b/src/frontend/src/screens/public/BusinessTrackPage.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { PublicShell } from './PublicShell'; +import { PublicHeroSlides } from './PublicHeroSlides'; import { IconVenue, IconBuyer, @@ -45,7 +46,9 @@ export function BusinessTrackPage() { className="kxp-hero__bg" style={{ background: 'linear-gradient(135deg, #12233b 0%, #23346b 60%, #0066b3 130%)' }} aria-hidden - /> + > + +
diff --git a/src/frontend/src/screens/public/PublicGatewayPage.tsx b/src/frontend/src/screens/public/PublicGatewayPage.tsx index 40afda0..880ff88 100644 --- a/src/frontend/src/screens/public/PublicGatewayPage.tsx +++ b/src/frontend/src/screens/public/PublicGatewayPage.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { PublicShell } from './PublicShell'; +import { PublicHeroSlides } from './PublicHeroSlides'; import { usePublicEvents, EventGrid } from './trackShared'; import { IconSparkles, @@ -32,13 +33,15 @@ export function PublicGatewayPage() { return ( - {/* 제품 히어로 — 배경 실사진은 후속(별도). 현재 V-4 그라디언트+패턴 폴백(CLS 0). */} + {/* 제품 히어로 — 배경 실사진 5장 크로스페이드 회전(그라디언트+패턴은 로드 실패 폴백). */}
+ > + +
{t('track.gateway.aiWatermark')}
diff --git a/src/frontend/src/screens/public/PublicHeroSlides.tsx b/src/frontend/src/screens/public/PublicHeroSlides.tsx new file mode 100644 index 0000000..1d58a42 --- /dev/null +++ b/src/frontend/src/screens/public/PublicHeroSlides.tsx @@ -0,0 +1,39 @@ +/* + * 공개 히어로 배경 회전 슬라이드 (§3C-V 확장) — 실사진 5장 크로스페이드. + * 각 히어로 배경 레이어(.kxp-hero__bg / .kxp-vhero__bg) 내부에 절대배치 자식으로 삽입한다. + * 회전 로직은 usePublicMotion.ts([data-hero-slides])가 담당(간격·정지조건·prefers-reduced-motion). + * 장식 배경이므로 aria-hidden + alt="" (헤드라인/스크림이 의미 전달). 로드 실패 시 그라디언트 폴백 노출. + * 이미지 alt(감사용): _workspace/hero_images.md — hero-1 곡면유리 파사드 … hero-5 제2전시장 돔. + */ +const HERO_COUNT = 5; + +export function PublicHeroSlides() { + return ( +
+ {Array.from({ length: HERO_COUNT }, (_, i) => { + const n = i + 1; + const first = i === 0; + return ( + { + // 로드 실패: 회전 대상에서 제외하고 그라디언트 폴백이 비치도록 숨김(비파괴). + const img = e.currentTarget; + img.dataset.failed = '1'; + img.classList.remove('is-active'); + img.style.display = 'none'; + }} + /> + ); + })} +
+ ); +} diff --git a/src/frontend/src/screens/public/VisitorTrackPage.tsx b/src/frontend/src/screens/public/VisitorTrackPage.tsx index ab12d60..1fe8207 100644 --- a/src/frontend/src/screens/public/VisitorTrackPage.tsx +++ b/src/frontend/src/screens/public/VisitorTrackPage.tsx @@ -12,6 +12,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { PublicShell } from './PublicShell'; +import { PublicHeroSlides } from './PublicHeroSlides'; import { usePublicEvents, EventGrid } from './trackShared'; import { AiAssistant } from './AiAssistant'; import { AiPlanningBriefing } from './AiPlanningBriefing'; @@ -149,9 +150,11 @@ export function VisitorTrackPage() { return ( {/* ── 1. 풀블리드 히어로 + AI 관람 도우미(중심) ── STITCH-REPLACE:hero ── */} - {/* 히어로 배경 실사진은 후속(별도) — 현재 V-4 그라디언트+패턴 폴백 렌더(CLS 0). */} + {/* 히어로 배경 실사진 5장 크로스페이드 회전(그라디언트+도트 패턴은 로드 실패 폴백). */}
-
+
+ +
{t('track.visitor.aiWatermark')} diff --git a/src/frontend/src/screens/public/public.css b/src/frontend/src/screens/public/public.css index 3e43374..38f6e79 100644 --- a/src/frontend/src/screens/public/public.css +++ b/src/frontend/src/screens/public/public.css @@ -4113,6 +4113,31 @@ a.kxp-ai__cite:hover { transform: none; } +/* 히어로 배경 회전 슬라이드(크로스페이드) — .kxp-hero__bg/.kxp-vhero__bg 내부 절대배치. + 폴백(그라디언트+패턴)은 부모 bg 레이어가 유지 → 이미지 위에 겹친다(로드 실패 시 비침). */ +.pub-hero__slides { + position: absolute; + inset: 0; + overflow: hidden; + z-index: 0; +} +.pub-hero__slide { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + opacity: 0; + transition: opacity 1200ms var(--ease-standard); + will-change: opacity; + pointer-events: none; + user-select: none; +} +.pub-hero__slide.is-active { + opacity: 1; +} + /* ── ④ 포스터 카드 리프트 + 이미지 줌 ──────────────────────────────────────── */ .pub-card--lift { position: relative; @@ -4222,6 +4247,11 @@ a.kxp-ai__cite:hover { bottom: 0; will-change: auto; } + /* 회전 정지(첫 장 고정) + 크로스페이드 전환 무효화 */ + .kxp .pub-hero__slide { + transition: none !important; + will-change: auto; + } .kxp .pub-card--lift, .kxp .pub-card__media img { transition: none; diff --git a/src/frontend/src/screens/public/usePublicMotion.ts b/src/frontend/src/screens/public/usePublicMotion.ts index 155a414..84cdb4a 100644 --- a/src/frontend/src/screens/public/usePublicMotion.ts +++ b/src/frontend/src/screens/public/usePublicMotion.ts @@ -191,6 +191,80 @@ export function usePublicMotion(rootRef: RefObject): void { cleanups.push(() => cancelAnimationFrame(rafId)); } + // ── ⑥ 히어로 배경 회전 슬라이드(크로스페이드) ────────────────────────────────── + // prefers-reduced-motion 시 첫 장 고정(회전 미배선). 정지: hover/focus·문서 숨김·오프스크린. + if (!reduce) { + root.querySelectorAll('[data-hero-slides]').forEach((box) => { + const slides = Array.from(box.querySelectorAll('img[data-slide]')); + if (slides.length < 2) return; // 폴백/단일 이미지는 회전 없음 + + const interval = 5500; // ~5.5s 노출 후 다음 장 + let idx = slides.findIndex((s) => s.classList.contains('is-active')); + if (idx < 0) { + idx = 0; + slides[0].classList.add('is-active'); + } + + let hovered = false; + let offscreen = false; + const isPaused = () => hovered || offscreen || document.hidden; + + const nextVisible = (from: number): number => { + for (let step = 1; step <= slides.length; step++) { + const j = (from + step) % slides.length; + if (slides[j].dataset.failed !== '1') return j; + } + return from; + }; + const tick = () => { + if (isPaused()) return; + const nx = nextVisible(idx); + if (nx === idx) return; // 표시 가능한 다음 장 없음 + slides[idx].classList.remove('is-active'); + slides[nx].classList.add('is-active'); + idx = nx; + }; + const timer = window.setInterval(tick, interval); + + const hero = + (box.closest('.pub-hero, .kxp-hero, .kxp-vhero') as HTMLElement | null) ?? + (box.closest('section') as HTMLElement | null) ?? + box; + const onEnter = () => { + hovered = true; + }; + const onLeave = () => { + hovered = false; + }; + hero.addEventListener('mouseenter', onEnter); + hero.addEventListener('mouseleave', onLeave); + hero.addEventListener('focusin', onEnter); + hero.addEventListener('focusout', onLeave); + + let io: IntersectionObserver | null = null; + if (supportsIO) { + io = new IntersectionObserver( + (entries) => { + entries.forEach((e) => { + offscreen = !e.isIntersecting; + }); + }, + { threshold: 0 }, + ); + io.observe(box); + } + + cleanups.push(() => { + window.clearInterval(timer); + hero.removeEventListener('mouseenter', onEnter); + hero.removeEventListener('mouseleave', onLeave); + hero.removeEventListener('focusin', onEnter); + hero.removeEventListener('focusout', onLeave); + io?.disconnect(); + }); + }); + } + return () => cleanups.forEach((fn) => fn()); }, [rootRef]); }