feat(public): rotating 5-slide hero backdrop with real photos on public tracks

Add PublicHeroSlides cross-fade component (5 real JPGs) wired into
gateway/visitor/business/agency hero backdrops. Rotation handled by
usePublicMotion ([data-hero-slides], reduced-motion aware); .pub-hero__slides
layering + is-active transition in public.css. Graceful gradient fallback on
image load error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 22:09:35 +09:00
parent c595560d82
commit 7316cd99c5
12 changed files with 161 additions and 6 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

@ -7,6 +7,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell'; import { PublicShell } from './PublicShell';
import { PublicHeroSlides } from './PublicHeroSlides';
import { import {
IconFactory, IconFactory,
IconShield, IconShield,
@ -33,7 +34,9 @@ export function AgencyTrackPage() {
className="kxp-hero__bg" className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #1a1330 0%, #3a2a6b 55%, #0066b3 130%)' }} style={{ background: 'linear-gradient(135deg, #1a1330 0%, #3a2a6b 55%, #0066b3 130%)' }}
aria-hidden aria-hidden
/> >
<PublicHeroSlides />
</div>
<div className="kxp-hero__scrim" aria-hidden /> <div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner"> <div className="kxp-hero__inner">
<div className="kxp-hero__content"> <div className="kxp-hero__content">

View File

@ -6,6 +6,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell'; import { PublicShell } from './PublicShell';
import { PublicHeroSlides } from './PublicHeroSlides';
import { import {
IconVenue, IconVenue,
IconBuyer, IconBuyer,
@ -45,7 +46,9 @@ export function BusinessTrackPage() {
className="kxp-hero__bg" className="kxp-hero__bg"
style={{ background: 'linear-gradient(135deg, #12233b 0%, #23346b 60%, #0066b3 130%)' }} style={{ background: 'linear-gradient(135deg, #12233b 0%, #23346b 60%, #0066b3 130%)' }}
aria-hidden aria-hidden
/> >
<PublicHeroSlides />
</div>
<div className="kxp-hero__scrim" aria-hidden /> <div className="kxp-hero__scrim" aria-hidden />
<div className="kxp-hero__inner"> <div className="kxp-hero__inner">
<div className="kxp-hero__content"> <div className="kxp-hero__content">

View File

@ -6,6 +6,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell'; import { PublicShell } from './PublicShell';
import { PublicHeroSlides } from './PublicHeroSlides';
import { usePublicEvents, EventGrid } from './trackShared'; import { usePublicEvents, EventGrid } from './trackShared';
import { import {
IconSparkles, IconSparkles,
@ -32,13 +33,15 @@ export function PublicGatewayPage() {
return ( return (
<PublicShell active="events" cta={t('track.gateway.ctaPreReg')}> <PublicShell active="events" cta={t('track.gateway.ctaPreReg')}>
{/* 제품 히어로 — 배경 실사진은 후속(별도). 현재 V-4 그라디언트+패턴 폴백(CLS 0). */} {/* 제품 히어로 — 배경 실사진 5장 크로스페이드 회전(그라디언트+패턴은 로드 실패 폴백). */}
<section className="kxp-hero pub-hero" aria-label={t('track.gateway.heroAria')}> <section className="kxp-hero pub-hero" aria-label={t('track.gateway.heroAria')}>
<div <div
className="kxp-hero__bg pub-fallback-gradient pub-pattern-grid" className="kxp-hero__bg pub-fallback-gradient pub-pattern-grid"
data-parallax="0.18" data-parallax="0.18"
aria-hidden aria-hidden
/> >
<PublicHeroSlides />
</div>
<div className="kxp-hero__scrim" aria-hidden /> <div className="kxp-hero__scrim" aria-hidden />
<span className="kxp-aiwm">{t('track.gateway.aiWatermark')}</span> <span className="kxp-aiwm">{t('track.gateway.aiWatermark')}</span>
<div className="kxp-hero__inner"> <div className="kxp-hero__inner">

View File

@ -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 (
<div className="pub-hero__slides" data-hero-slides aria-hidden>
{Array.from({ length: HERO_COUNT }, (_, i) => {
const n = i + 1;
const first = i === 0;
return (
<img
key={n}
className={`pub-hero__slide${first ? ' is-active' : ''}`}
src={`/media/hero/hero-${n}.jpg`}
alt=""
data-slide={i}
draggable={false}
decoding="async"
loading={first ? 'eager' : 'lazy'}
fetchPriority={first ? 'high' : 'low'}
onError={(e) => {
// 로드 실패: 회전 대상에서 제외하고 그라디언트 폴백이 비치도록 숨김(비파괴).
const img = e.currentTarget;
img.dataset.failed = '1';
img.classList.remove('is-active');
img.style.display = 'none';
}}
/>
);
})}
</div>
);
}

View File

@ -12,6 +12,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PublicShell } from './PublicShell'; import { PublicShell } from './PublicShell';
import { PublicHeroSlides } from './PublicHeroSlides';
import { usePublicEvents, EventGrid } from './trackShared'; import { usePublicEvents, EventGrid } from './trackShared';
import { AiAssistant } from './AiAssistant'; import { AiAssistant } from './AiAssistant';
import { AiPlanningBriefing } from './AiPlanningBriefing'; import { AiPlanningBriefing } from './AiPlanningBriefing';
@ -149,9 +150,11 @@ export function VisitorTrackPage() {
return ( return (
<PublicShell active="visit" track="visitor" cta={t('track.visitor.ctaPreReg')}> <PublicShell active="visit" track="visitor" cta={t('track.visitor.ctaPreReg')}>
{/* ── 1. 풀블리드 히어로 + AI 관람 도우미(중심) ── STITCH-REPLACE:hero ── */} {/* ── 1. 풀블리드 히어로 + AI 관람 도우미(중심) ── STITCH-REPLACE:hero ── */}
{/* 히어로 배경 실사진은 후속(별도) — 현재 V-4 그라디언트+패턴 폴백 렌더(CLS 0). */} {/* 히어로 배경 실사진 5장 크로스페이드 회전(그라디언트+도트 패턴은 로드 실패 폴백). */}
<section className="kxp-vhero pub-hero" id="hero" aria-label={t('track.visitor.heroAria')}> <section className="kxp-vhero pub-hero" id="hero" aria-label={t('track.visitor.heroAria')}>
<div className="kxp-vhero__bg pub-pattern-dots" data-parallax="0.18" aria-hidden /> <div className="kxp-vhero__bg pub-pattern-dots" data-parallax="0.18" aria-hidden>
<PublicHeroSlides />
</div>
<div className="kxp-vhero__scrim" aria-hidden /> <div className="kxp-vhero__scrim" aria-hidden />
<span className="kxp-vhero__wm" aria-hidden> <span className="kxp-vhero__wm" aria-hidden>
{t('track.visitor.aiWatermark')} {t('track.visitor.aiWatermark')}

View File

@ -4113,6 +4113,31 @@ a.kxp-ai__cite:hover {
transform: none; 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 { .pub-card--lift {
position: relative; position: relative;
@ -4222,6 +4247,11 @@ a.kxp-ai__cite:hover {
bottom: 0; bottom: 0;
will-change: auto; will-change: auto;
} }
/* 회전 정지(첫 장 고정) + 크로스페이드 전환 무효화 */
.kxp .pub-hero__slide {
transition: none !important;
will-change: auto;
}
.kxp .pub-card--lift, .kxp .pub-card--lift,
.kxp .pub-card__media img { .kxp .pub-card__media img {
transition: none; transition: none;

View File

@ -191,6 +191,80 @@ export function usePublicMotion(rootRef: RefObject<HTMLElement | null>): void {
cleanups.push(() => cancelAnimationFrame(rafId)); cleanups.push(() => cancelAnimationFrame(rafId));
} }
// ── ⑥ 히어로 배경 회전 슬라이드(크로스페이드) ──────────────────────────────────
// prefers-reduced-motion 시 첫 장 고정(회전 미배선). 정지: hover/focus·문서 숨김·오프스크린.
if (!reduce) {
root.querySelectorAll<HTMLElement>('[data-hero-slides]').forEach((box) => {
const slides = Array.from(box.querySelectorAll<HTMLElement>('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()); return () => cleanups.forEach((fn) => fn());
}, [rootRef]); }, [rootRef]);
} }