[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:
Mina Choi 2026-09-30 17:13:24 +09:00
parent 7a7f00dd15
commit a0b14dfca1
15 changed files with 212 additions and 114 deletions

View File

@ -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`)
- 첫 화면에 태그·칩(‘주차 가능’, 지역 말풍선)을 두지 않는다
- 부티크 첫 화면은 모바일도 문(아치) 모양이다. 전체화면 사진으로 되돌리지 않는다

View File

@ -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;

View File

@ -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))}>

View File

@ -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))}>

View File

@ -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" />
</>
) : (
<>

View File

@ -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))}>

View File

@ -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 ? (

View File

@ -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;
}

View File

@ -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))}>

View File

@ -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') && (

View File

@ -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))}>

View File

@ -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))}>

View File

@ -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>
);
}

View File

@ -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; }
}

View File

@ -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', () => {