[fix] solution/site,shared: 첫 화면을 목업 구조로 — 캐치프레이즈 하나 고정, 아래 줄 순환
/s/stay 목업은 캐치프레이즈(tagline) 한 줄이 고정이고 그 아래 줄에서 문구가 돈다 (2026-09-11 "메인 모토는 고정, 그 아래 변동되는 모토"). 템플릿은 큰 줄에 공통 문구 10개를 돌리고 있었고, 앞 커밋의 소문구는 "명소까지 거리" 로 목업과 달랐다. - sections/HeroCatchphrase.tsx: HeroCatchphrase = tagline 고정(없으면 공통 문구 중 사이트별 하나), HeroRotator = narrative.catchphrases 를 일반→계절→일반→월→일반→날씨 순서로 6초마다, 줄 높이는 가장 긴 문구 - 9종 Hero: 소문구 자리를 HeroRotator 로. kit/heroSub.ts 삭제 - shared: catchphrases 항목에 season·month·weather - 테스트: 고정·폴백·비숙박·빈 순환·순서·날씨 분류 tsc·eslint 통과 · vitest 127 passed · 9종 모바일 캡처, 라운드·부티크 6초 간격 순환·높이 고정 확인 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a7f00dd15
commit
a0b14dfca1
@ -85,7 +85,10 @@
|
||||
|
||||
## 6. 값 보여주기
|
||||
|
||||
- **첫 화면은 캐치프레이즈 → 그 아래 소문구.** 소문구는 사장님 문구, 없으면 가장 가까운 명소까지 거리(`kit/heroSub.ts`)
|
||||
- **첫 화면은 캐치프레이즈 한 줄(고정) → 그 아래 순환 문구.** `/s/stay` 목업과 같다(`sections/HeroCatchphrase.tsx`)
|
||||
- 캐치프레이즈 = `narrative.tagline`. 없으면 공통 문구 10개 중 사이트마다 정해진 하나
|
||||
- 순환 = `narrative.catchphrases`, 일반 → 계절 → 일반 → 월 → 일반 → 날씨 순서, 6초. 없으면 줄을 두지 않는다
|
||||
- 두 값은 COPY 잡이 소개문과 함께 만든다(fact `tagline` · `catchphrases`, `services/copy_steps.py`)
|
||||
- 첫 화면에 태그·칩(‘주차 가능’, 지역 말풍선)을 두지 않는다
|
||||
- 부티크 첫 화면은 모바일도 문(아치) 모양이다. 전체화면 사진으로 되돌리지 않는다
|
||||
|
||||
|
||||
@ -322,10 +322,19 @@ export interface RouteEntry {
|
||||
}
|
||||
|
||||
export interface Narrative {
|
||||
/** 히어로 둘째 줄에서 순환할 문구. */
|
||||
/** 히어로 둘째 줄에서 순환할 문구. 첫째 줄(캐치프레이즈)은 `tagline` 하나로 고정이다. */
|
||||
catchphrases?: {
|
||||
version: 1;
|
||||
items: {text: string; kind?: 'general' | 'season' | 'month' | 'weather'}[];
|
||||
items: {
|
||||
text: string;
|
||||
kind?: 'general' | 'season' | 'month' | 'weather';
|
||||
/** kind=season 일 때 봄·여름·가을·겨울. */
|
||||
season?: string;
|
||||
/** kind=month 일 때 1~12. */
|
||||
month?: number;
|
||||
/** kind=weather 일 때 맑음·흐림·비·눈. */
|
||||
weather?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
heroHeadline?: string;
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
|
||||
@ -13,7 +12,6 @@ export function Hero() {
|
||||
.filter((image) => image.alt?.trim())
|
||||
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary));
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const sub = heroSub(payload);
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
const link = bookingLinks(payload)[0];
|
||||
@ -31,7 +29,7 @@ export function Hero() {
|
||||
<span className="no" /> 오늘의 문장
|
||||
</p>
|
||||
{(lodging || fixed) && <p className="rot">{lodging ? <HeroCatchphrase /> : fixed}</p>}
|
||||
{lodging && sub && <p className="m sub">{sub}</p>}
|
||||
<HeroRotator className="m sub" />
|
||||
<div className="hx">
|
||||
{stay ? (
|
||||
<button type="button" className="line line--ink" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {pad2} from './ui';
|
||||
import {Weather} from './Weather';
|
||||
@ -27,7 +26,6 @@ export function Hero() {
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [total]);
|
||||
const sub = heroSub(payload);
|
||||
const english = place.englishName?.trim();
|
||||
const locality = [place.addressLocality, place.addressRegion].find(Boolean);
|
||||
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||
@ -61,7 +59,7 @@ export function Hero() {
|
||||
<p className="b-hero-cp">
|
||||
<HeroCatchphrase />
|
||||
</p>
|
||||
{sub && <p className="b-hero-ko">{sub}</p>}
|
||||
<HeroRotator className="b-hero-ko" />
|
||||
<div className="b-hero-act">
|
||||
{stayBooking ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {galleryImages, isSectionEnabled} from '@site/lib/derive';
|
||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {Car} from '../paper/Car';
|
||||
import {MoodIcon, Weather} from './Weather';
|
||||
|
||||
@ -19,7 +18,7 @@ export function Hero() {
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const where = place.addressLocality ?? place.addressRegion;
|
||||
const sub = [where, heroSub(payload)].filter(Boolean).join(' · ');
|
||||
const sub = [where, fixed].filter(Boolean).join(' · ');
|
||||
const showWeather = isSectionEnabled(payload, 'weather');
|
||||
const weather = useLiveWeather({
|
||||
initial: payload.local.weather,
|
||||
@ -57,7 +56,8 @@ export function Hero() {
|
||||
<HeroCatchphrase />
|
||||
</p>
|
||||
<h1 className="nm">{place.name}</h1>
|
||||
{sub && <p className="sub">{sub}</p>}
|
||||
{where && <p className="sub">{where}</p>}
|
||||
<HeroRotator className="sub" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -18,7 +17,6 @@ export function Hero() {
|
||||
.slice(0, MAX_SLIDES);
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const sub = heroSub(payload);
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
@ -57,7 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
import {useEffect, useState, type CSSProperties} from 'react';
|
||||
import {PlaceCategory, type WeatherSky} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {HeroRotator, useMainCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||
import {useWeatherNotes} from '@site/lib/use-weather-note';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView, weatherBand, weatherMood} from '@site/lib/derive';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import {Hills, Sky} from './Art';
|
||||
|
||||
const STEP_MS = 35;
|
||||
const ROTATE_MS = 4500;
|
||||
|
||||
export function Pop({text}: {text: string}) {
|
||||
const words = text.split(/\s+/).map((word) => [...word]);
|
||||
@ -33,26 +31,6 @@ export function Pop({text}: {text: string}) {
|
||||
);
|
||||
}
|
||||
|
||||
function useCatchphrase(): string | undefined {
|
||||
const {place, narrative} = useSite();
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const own = (narrative.catchphrases?.items ?? [])
|
||||
.filter((item) => (item.kind ?? 'general') === 'general')
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean);
|
||||
const phrases = own.length > 0 ? own : lodging ? lines : [];
|
||||
const [index, setIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (phrases.length < 2) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
|
||||
}, ROTATE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [phrases.length]);
|
||||
return phrases.length > 0 ? phrases[index % phrases.length] : undefined;
|
||||
}
|
||||
|
||||
function WeatherCard() {
|
||||
const payload = useSite();
|
||||
const weather = useLiveWeather({
|
||||
@ -102,9 +80,8 @@ function WeatherCard() {
|
||||
export function Hero() {
|
||||
const payload = useSite();
|
||||
const {place, narrative} = payload;
|
||||
const phrase = useCatchphrase();
|
||||
const phrase = useMainCatchphrase();
|
||||
const region = [place.addressRegion, place.addressLocality].filter(Boolean).join(' ');
|
||||
const sub = heroSub(payload);
|
||||
const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||
const link = bookingLinks(payload)[0];
|
||||
|
||||
@ -120,7 +97,7 @@ export function Hero() {
|
||||
<Pop text={phrase} />
|
||||
</p>
|
||||
)}
|
||||
{sub && <p className="sub">{sub}</p>}
|
||||
<HeroRotator className="sub" />
|
||||
{(stay || link || place.phone) && (
|
||||
<div className="hgo">
|
||||
{stay ? (
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import type {SitePayload} from '@o2o/shared';
|
||||
|
||||
export function heroSub(payload: SitePayload): string | undefined {
|
||||
const fixed = payload.narrative.tagline ?? payload.narrative.heroSubline;
|
||||
if (fixed) return fixed;
|
||||
const nearest = [...payload.local.attractions]
|
||||
.filter((spot) => spot.distanceText)
|
||||
.sort((a, b) => (a.distanceMeters ?? Infinity) - (b.distanceMeters ?? Infinity))[0];
|
||||
return nearest ? `${nearest.name}까지 ${nearest.distanceText}` : undefined;
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -18,7 +17,6 @@ export function Hero() {
|
||||
.slice(0, MAX_SLIDES);
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const sub = heroSub(payload);
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
@ -57,7 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {isSectionEnabled} from '@site/lib/derive';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {Weather} from './Weather';
|
||||
import {Car} from './Car';
|
||||
|
||||
@ -14,7 +13,6 @@ export function Hero() {
|
||||
.filter((image) => image.alt?.trim())
|
||||
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
|
||||
.slice(0, MAX_SLIDES);
|
||||
const sub = heroSub(payload);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -45,7 +43,7 @@ export function Hero() {
|
||||
<p className="cp">
|
||||
<HeroCatchphrase />
|
||||
</p>
|
||||
{sub && <p className="tagline">{sub}</p>}
|
||||
<HeroRotator className="tagline" />
|
||||
</div>
|
||||
</section>
|
||||
{isSectionEnabled(payload, 'weather') && (
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -19,7 +18,6 @@ export function Hero() {
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const sub = heroSub(payload);
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
|
||||
@ -57,7 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {heroSub} from '../kit/heroSub';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -18,7 +17,6 @@ export function Hero() {
|
||||
.slice(0, MAX_SLIDES);
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const sub = heroSub(payload);
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
@ -57,7 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -1,37 +1,127 @@
|
||||
import {useEffect, useState, type CSSProperties} from 'react';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {useEffect, useLayoutEffect, useRef, useState, type CSSProperties} from 'react';
|
||||
import {PlaceCategory, type Narrative} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import './hero-catchphrase.css';
|
||||
|
||||
export function HeroCatchphrase({children}: {children?: string}) {
|
||||
const {place, narrative} = useSite();
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const [index, setIndex] = useState(0);
|
||||
const own = (narrative.catchphrases?.items ?? [])
|
||||
.filter((item) => (item.kind ?? 'general') === 'general')
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean);
|
||||
const phrases = own.length > 0 ? own : lines;
|
||||
useEffect(() => {
|
||||
if (!lodging) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
|
||||
}, 6000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [lodging, phrases.length]);
|
||||
type Item = NonNullable<Narrative['catchphrases']>['items'][number];
|
||||
|
||||
if (!lodging) return children;
|
||||
const text = phrases[index % phrases.length];
|
||||
const ROTATE_MS = 6000;
|
||||
const SEASON_OF_MONTH = ['겨울', '겨울', '봄', '봄', '봄', '여름', '여름', '여름', '가을', '가을', '가을', '겨울'];
|
||||
|
||||
function stableIndex(key: string, size: number): number {
|
||||
let hash = 0;
|
||||
for (const ch of key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return hash % size;
|
||||
}
|
||||
|
||||
export function weatherBucket(condition?: string): string | undefined {
|
||||
if (!condition) return undefined;
|
||||
if (/눈/.test(condition)) return '눈';
|
||||
if (/비|소나기/.test(condition)) return '비';
|
||||
if (/흐림|구름/.test(condition)) return '흐림';
|
||||
if (/맑/.test(condition)) return '맑음';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildCycle(items: Item[], month: number, weather?: string, shuffle = true): string[] {
|
||||
const text = (list: Item[]) => list.map((item) => item.text.trim()).filter(Boolean);
|
||||
const general = text(items.filter((item) => (item.kind ?? 'general') === 'general'));
|
||||
if (shuffle) {
|
||||
for (let i = general.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[general[i], general[j]] = [general[j], general[i]];
|
||||
}
|
||||
}
|
||||
const pick = (list: string[]) => (list.length ? list[Math.floor(Math.random() * list.length)] : undefined);
|
||||
const season = text(items.filter((item) => item.kind === 'season' && item.season === SEASON_OF_MONTH[month - 1]));
|
||||
const monthly = text(items.filter((item) => item.kind === 'month' && Number(item.month) === month));
|
||||
const weathered = weather ? text(items.filter((item) => item.kind === 'weather' && item.weather === weather)) : [];
|
||||
const out: string[] = [];
|
||||
let g = 0;
|
||||
const nextGeneral = () => general[g++ % Math.max(general.length, 1)];
|
||||
for (let round = 0; round < Math.max(1, Math.ceil(general.length / 3)); round += 1) {
|
||||
out.push(nextGeneral(), shuffle ? pick(season) ?? '' : season[0] ?? '', nextGeneral(), shuffle ? pick(monthly) ?? '' : monthly[0] ?? '', nextGeneral());
|
||||
if (weathered.length) out.push(shuffle ? pick(weathered) ?? '' : weathered[0]);
|
||||
}
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
export function useMainCatchphrase(fallback?: string): string | undefined {
|
||||
const {place, narrative, site} = useSite();
|
||||
if (place.category !== PlaceCategory.LODGING) return undefined;
|
||||
const own = (narrative.tagline ?? narrative.heroSubline ?? fallback)?.trim();
|
||||
return own || lines[stableIndex(site.siteId || place.name, lines.length)];
|
||||
}
|
||||
|
||||
export function HeroCatchphrase({children}: {children?: string}) {
|
||||
const text = useMainCatchphrase(children);
|
||||
if (!text) return children;
|
||||
return (
|
||||
<span className="hero-catchphrase">
|
||||
{children && <span className="hero-catchphrase-fixed">{children}</span>}
|
||||
<span className="w4d-line w4d-line--in" key={index}>
|
||||
{text.split(/\s+/).map((word, i) => (
|
||||
<span key={i}>{i > 0 && ' '}<span className="w4d-w"><span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span></span></span>
|
||||
))}
|
||||
</span>
|
||||
{text.split(/\s+/).map((word, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ' '}
|
||||
<span className="w4d-w">
|
||||
<span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroRotator({className}: {className?: string}) {
|
||||
const {place, narrative, local} = useSite();
|
||||
const box = narrative.catchphrases;
|
||||
const items = box?.items ?? [];
|
||||
const month = new Date().getMonth() + 1;
|
||||
const [cycle, setCycle] = useState(() => buildCycle(items, month, weatherBucket(local.weather?.condition), false));
|
||||
const [index, setIndex] = useState(0);
|
||||
const [minHeight, setMinHeight] = useState<number>();
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCycle(buildCycle(box?.items ?? [], new Date().getMonth() + 1, weatherBucket(local.weather?.condition)));
|
||||
}, [box, local.weather?.condition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cycle.length < 2) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % cycle.length);
|
||||
}, ROTATE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [cycle.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
const width = node.getBoundingClientRect().width;
|
||||
if (!width) return;
|
||||
const probe = node.cloneNode(false) as HTMLElement;
|
||||
probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;min-height:0;width:${width}px`;
|
||||
node.parentNode?.appendChild(probe);
|
||||
let max = 0;
|
||||
for (const line of cycle) {
|
||||
probe.textContent = line;
|
||||
max = Math.max(max, probe.getBoundingClientRect().height);
|
||||
}
|
||||
probe.remove();
|
||||
setMinHeight(Math.ceil(max));
|
||||
};
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [cycle]);
|
||||
|
||||
if (place.category !== PlaceCategory.LODGING || cycle.length === 0) return null;
|
||||
return (
|
||||
<p ref={ref} className={['hero-rotator', className].filter(Boolean).join(' ')} style={{minHeight}} aria-live="off">
|
||||
<span className="hero-rotator-line" key={`${index}-${cycle[index % cycle.length]}`}>
|
||||
{cycle[index % cycle.length]}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
.hero-catchphrase { display: block; }
|
||||
.hero-catchphrase-fixed { display: block; }
|
||||
.hero-catchphrase .w4d-line { display: block; margin-top: 0.5rem; min-height: 1.5em; word-break: keep-all; }
|
||||
.hero-catchphrase { display: block; word-break: keep-all; }
|
||||
.hero-catchphrase .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; }
|
||||
.hero-catchphrase .w4d-w > span { display: inline-block; animation: lodging-word-in 0.45s both; animation-delay: var(--w4d-d); }
|
||||
.hero-rotator { word-break: keep-all; }
|
||||
.hero-rotator-line { display: block; animation: hero-rotator-in 0.6s both; }
|
||||
@keyframes lodging-word-in {
|
||||
from { opacity: 0; transform: translateY(100%); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-catchphrase .w4d-w > span { animation: none; }
|
||||
@keyframes hero-rotator-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-catchphrase .w4d-w > span, .hero-rotator-line { animation: none; }
|
||||
}
|
||||
|
||||
@ -1,26 +1,65 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {PlaceCategory, type SitePayload} from '@o2o/shared';
|
||||
import {SiteProvider} from '@site/lib/site-context';
|
||||
import {LayoutProvider} from '@site/lib/layout';
|
||||
import {LAYOUTS} from '@site/layouts';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import {HeroCatchphrase} from './HeroCatchphrase';
|
||||
import {buildCycle, HeroCatchphrase, HeroRotator, weatherBucket} from './HeroCatchphrase';
|
||||
|
||||
it('keeps the original lodging tagline in the static HTML', () => {
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={MOONLIGHT_STAY_PAYLOAD}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>고유문구</HeroCatchphrase></LayoutProvider></SiteProvider>);
|
||||
expect(html).toContain('<span class="hero-catchphrase-fixed">고유문구</span>');
|
||||
expect(html).toContain('w4d-line--in');
|
||||
const text = html.replace(/<[^>]+>/g, '');
|
||||
expect(text).toContain('고유문구' + lines[0]);
|
||||
const text = (html: string) => html.replace(/<[^>]+>/g, '');
|
||||
const render = (payload: SitePayload, node: React.ReactNode) =>
|
||||
renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}>{node}</LayoutProvider></SiteProvider>);
|
||||
|
||||
it('shows the one fixed catchphrase and nothing that rotates in its place', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.narrative.tagline = '담 너머 백 년 된 집';
|
||||
expect(text(render(payload, <HeroCatchphrase />))).toBe('담 너머 백 년 된 집');
|
||||
});
|
||||
|
||||
it('does not add lodging copy or controls to another category', () => {
|
||||
it('falls back to one common line when the place has no catchphrase', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
delete payload.narrative.tagline;
|
||||
delete payload.narrative.heroSubline;
|
||||
expect(lines).toContain(text(render(payload, <HeroCatchphrase />)));
|
||||
});
|
||||
|
||||
it('does not add lodging copy to another category', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.place.category = PlaceCategory.CAFE;
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>카페 소개</HeroCatchphrase></LayoutProvider></SiteProvider>);
|
||||
expect(html).toBe('카페 소개');
|
||||
expect(render(payload, <HeroCatchphrase>카페 소개</HeroCatchphrase>)).toBe('카페 소개');
|
||||
});
|
||||
|
||||
it('leaves the rotating line out when there is nothing to rotate', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
delete payload.narrative.catchphrases;
|
||||
expect(render(payload, <HeroRotator />)).toBe('');
|
||||
});
|
||||
|
||||
it('rotates general, season, general, month, general, weather like the stay mockup', () => {
|
||||
const cycle = buildCycle(
|
||||
[
|
||||
{text: '일반1', kind: 'general'},
|
||||
{text: '일반2', kind: 'general'},
|
||||
{text: '일반3', kind: 'general'},
|
||||
{text: '가을 문구', kind: 'season', season: '가을'},
|
||||
{text: '봄 문구', kind: 'season', season: '봄'},
|
||||
{text: '9월 문구', kind: 'month', month: 9},
|
||||
{text: '비 문구', kind: 'weather', weather: '비'},
|
||||
],
|
||||
9,
|
||||
'비',
|
||||
false,
|
||||
);
|
||||
expect(cycle).toEqual(['일반1', '가을 문구', '일반2', '9월 문구', '일반3', '비 문구']);
|
||||
});
|
||||
|
||||
it('reads the sky into the four weather buckets', () => {
|
||||
expect(weatherBucket('구름많음')).toBe('흐림');
|
||||
expect(weatherBucket('비/눈')).toBe('눈');
|
||||
expect(weatherBucket('소나기')).toBe('비');
|
||||
expect(weatherBucket('맑음')).toBe('맑음');
|
||||
});
|
||||
|
||||
it('ships ten distinct short phrases', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user