[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. 값 보여주기
|
## 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 {
|
export interface Narrative {
|
||||||
/** 히어로 둘째 줄에서 순환할 문구. */
|
/** 히어로 둘째 줄에서 순환할 문구. 첫째 줄(캐치프레이즈)은 `tagline` 하나로 고정이다. */
|
||||||
catchphrases?: {
|
catchphrases?: {
|
||||||
version: 1;
|
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;
|
heroHeadline?: string;
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import {PlaceCategory} from '@o2o/shared';
|
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 {useSite} from '@site/lib/site-context';
|
||||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {BOOK_EVENT} from '../paper/Rooms';
|
import {BOOK_EVENT} from '../paper/Rooms';
|
||||||
import {Weather} from './Weather';
|
import {Weather} from './Weather';
|
||||||
|
|
||||||
@ -13,7 +12,6 @@ export function Hero() {
|
|||||||
.filter((image) => image.alt?.trim())
|
.filter((image) => image.alt?.trim())
|
||||||
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary));
|
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary));
|
||||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||||
const sub = heroSub(payload);
|
|
||||||
const lodging = place.category === PlaceCategory.LODGING;
|
const lodging = place.category === PlaceCategory.LODGING;
|
||||||
const stay = lodging ? stayBookingView(payload) : null;
|
const stay = lodging ? stayBookingView(payload) : null;
|
||||||
const link = bookingLinks(payload)[0];
|
const link = bookingLinks(payload)[0];
|
||||||
@ -31,7 +29,7 @@ export function Hero() {
|
|||||||
<span className="no" /> 오늘의 문장
|
<span className="no" /> 오늘의 문장
|
||||||
</p>
|
</p>
|
||||||
{(lodging || fixed) && <p className="rot">{lodging ? <HeroCatchphrase /> : fixed}</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">
|
<div className="hx">
|
||||||
{stay ? (
|
{stay ? (
|
||||||
<button type="button" className="line line--ink" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
<button type="button" className="line line--ink" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import {useEffect, useState} from 'react';
|
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 {useSite} from '@site/lib/site-context';
|
||||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||||
import {PlaceCategory} from '@o2o/shared';
|
import {PlaceCategory} from '@o2o/shared';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {BOOK_EVENT} from '../paper/Rooms';
|
import {BOOK_EVENT} from '../paper/Rooms';
|
||||||
import {pad2} from './ui';
|
import {pad2} from './ui';
|
||||||
import {Weather} from './Weather';
|
import {Weather} from './Weather';
|
||||||
@ -27,7 +26,6 @@ export function Hero() {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [total]);
|
}, [total]);
|
||||||
const sub = heroSub(payload);
|
|
||||||
const english = place.englishName?.trim();
|
const english = place.englishName?.trim();
|
||||||
const locality = [place.addressLocality, place.addressRegion].find(Boolean);
|
const locality = [place.addressLocality, place.addressRegion].find(Boolean);
|
||||||
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||||
@ -61,7 +59,7 @@ export function Hero() {
|
|||||||
<p className="b-hero-cp">
|
<p className="b-hero-cp">
|
||||||
<HeroCatchphrase />
|
<HeroCatchphrase />
|
||||||
</p>
|
</p>
|
||||||
{sub && <p className="b-hero-ko">{sub}</p>}
|
<HeroRotator className="b-hero-ko" />
|
||||||
<div className="b-hero-act">
|
<div className="b-hero-act">
|
||||||
{stayBooking ? (
|
{stayBooking ? (
|
||||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import {PlaceCategory} from '@o2o/shared';
|
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 {useSite} from '@site/lib/site-context';
|
||||||
import {galleryImages, isSectionEnabled} from '@site/lib/derive';
|
import {galleryImages, isSectionEnabled} from '@site/lib/derive';
|
||||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {Car} from '../paper/Car';
|
import {Car} from '../paper/Car';
|
||||||
import {MoodIcon, Weather} from './Weather';
|
import {MoodIcon, Weather} from './Weather';
|
||||||
|
|
||||||
@ -19,7 +18,7 @@ export function Hero() {
|
|||||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||||
const lodging = place.category === PlaceCategory.LODGING;
|
const lodging = place.category === PlaceCategory.LODGING;
|
||||||
const where = place.addressLocality ?? place.addressRegion;
|
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 showWeather = isSectionEnabled(payload, 'weather');
|
||||||
const weather = useLiveWeather({
|
const weather = useLiveWeather({
|
||||||
initial: payload.local.weather,
|
initial: payload.local.weather,
|
||||||
@ -57,7 +56,8 @@ export function Hero() {
|
|||||||
<HeroCatchphrase />
|
<HeroCatchphrase />
|
||||||
</p>
|
</p>
|
||||||
<h1 className="nm">{place.name}</h1>
|
<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 {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 {useSite} from '@site/lib/site-context';
|
||||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {Car} from '../paper/Car';
|
import {Car} from '../paper/Car';
|
||||||
import {BOOK_EVENT} from '../paper/Rooms';
|
import {BOOK_EVENT} from '../paper/Rooms';
|
||||||
import {Weather} from './Weather';
|
import {Weather} from './Weather';
|
||||||
@ -18,7 +17,6 @@ export function Hero() {
|
|||||||
.slice(0, MAX_SLIDES);
|
.slice(0, MAX_SLIDES);
|
||||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||||
const lodging = place.category === PlaceCategory.LODGING;
|
const lodging = place.category === PlaceCategory.LODGING;
|
||||||
const sub = heroSub(payload);
|
|
||||||
const region = place.addressLocality ?? place.addressRegion;
|
const region = place.addressLocality ?? place.addressRegion;
|
||||||
const booking = bookingLinks(payload)[0];
|
const booking = bookingLinks(payload)[0];
|
||||||
const stay = lodging ? stayBookingView(payload) : null;
|
const stay = lodging ? stayBookingView(payload) : null;
|
||||||
@ -57,7 +55,7 @@ export function Hero() {
|
|||||||
) : (
|
) : (
|
||||||
fixed && <p className="hl in">{fixed}</p>
|
fixed && <p className="hl in">{fixed}</p>
|
||||||
)}
|
)}
|
||||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
<HeroRotator className="tagline" />
|
||||||
<div className="hero-cta">
|
<div className="hero-cta">
|
||||||
{stay ? (
|
{stay ? (
|
||||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import {useEffect, useState, type CSSProperties} from 'react';
|
import {useEffect, useState, type CSSProperties} from 'react';
|
||||||
import {PlaceCategory, type WeatherSky} from '@o2o/shared';
|
import {PlaceCategory, type WeatherSky} from '@o2o/shared';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
|
import {HeroRotator, useMainCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||||
import {useWeatherNotes} from '@site/lib/use-weather-note';
|
import {useWeatherNotes} from '@site/lib/use-weather-note';
|
||||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView, weatherBand, weatherMood} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView, weatherBand, weatherMood} from '@site/lib/derive';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {BOOK_EVENT} from '../paper/Rooms';
|
import {BOOK_EVENT} from '../paper/Rooms';
|
||||||
import lines from '@site/lib/lodging-catchphrases.json';
|
|
||||||
import {Hills, Sky} from './Art';
|
import {Hills, Sky} from './Art';
|
||||||
|
|
||||||
const STEP_MS = 35;
|
const STEP_MS = 35;
|
||||||
const ROTATE_MS = 4500;
|
|
||||||
|
|
||||||
export function Pop({text}: {text: string}) {
|
export function Pop({text}: {text: string}) {
|
||||||
const words = text.split(/\s+/).map((word) => [...word]);
|
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() {
|
function WeatherCard() {
|
||||||
const payload = useSite();
|
const payload = useSite();
|
||||||
const weather = useLiveWeather({
|
const weather = useLiveWeather({
|
||||||
@ -102,9 +80,8 @@ function WeatherCard() {
|
|||||||
export function Hero() {
|
export function Hero() {
|
||||||
const payload = useSite();
|
const payload = useSite();
|
||||||
const {place, narrative} = payload;
|
const {place, narrative} = payload;
|
||||||
const phrase = useCatchphrase();
|
const phrase = useMainCatchphrase();
|
||||||
const region = [place.addressRegion, place.addressLocality].filter(Boolean).join(' ');
|
const region = [place.addressRegion, place.addressLocality].filter(Boolean).join(' ');
|
||||||
const sub = heroSub(payload);
|
|
||||||
const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||||
const link = bookingLinks(payload)[0];
|
const link = bookingLinks(payload)[0];
|
||||||
|
|
||||||
@ -120,7 +97,7 @@ export function Hero() {
|
|||||||
<Pop text={phrase} />
|
<Pop text={phrase} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{sub && <p className="sub">{sub}</p>}
|
<HeroRotator className="sub" />
|
||||||
{(stay || link || place.phone) && (
|
{(stay || link || place.phone) && (
|
||||||
<div className="hgo">
|
<div className="hgo">
|
||||||
{stay ? (
|
{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 {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 {useSite} from '@site/lib/site-context';
|
||||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {Car} from '../paper/Car';
|
import {Car} from '../paper/Car';
|
||||||
import {BOOK_EVENT} from '../paper/Rooms';
|
import {BOOK_EVENT} from '../paper/Rooms';
|
||||||
import {Weather} from './Weather';
|
import {Weather} from './Weather';
|
||||||
@ -18,7 +17,6 @@ export function Hero() {
|
|||||||
.slice(0, MAX_SLIDES);
|
.slice(0, MAX_SLIDES);
|
||||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||||
const lodging = place.category === PlaceCategory.LODGING;
|
const lodging = place.category === PlaceCategory.LODGING;
|
||||||
const sub = heroSub(payload);
|
|
||||||
const region = place.addressLocality ?? place.addressRegion;
|
const region = place.addressLocality ?? place.addressRegion;
|
||||||
const booking = bookingLinks(payload)[0];
|
const booking = bookingLinks(payload)[0];
|
||||||
const stay = lodging ? stayBookingView(payload) : null;
|
const stay = lodging ? stayBookingView(payload) : null;
|
||||||
@ -57,7 +55,7 @@ export function Hero() {
|
|||||||
) : (
|
) : (
|
||||||
fixed && <p className="hl in">{fixed}</p>
|
fixed && <p className="hl in">{fixed}</p>
|
||||||
)}
|
)}
|
||||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
<HeroRotator className="tagline" />
|
||||||
<div className="hero-cta">
|
<div className="hero-cta">
|
||||||
{stay ? (
|
{stay ? (
|
||||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
<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 {useSite} from '@site/lib/site-context';
|
||||||
import {isSectionEnabled} from '@site/lib/derive';
|
import {isSectionEnabled} from '@site/lib/derive';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {Weather} from './Weather';
|
import {Weather} from './Weather';
|
||||||
import {Car} from './Car';
|
import {Car} from './Car';
|
||||||
|
|
||||||
@ -14,7 +13,6 @@ export function Hero() {
|
|||||||
.filter((image) => image.alt?.trim())
|
.filter((image) => image.alt?.trim())
|
||||||
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
|
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
|
||||||
.slice(0, MAX_SLIDES);
|
.slice(0, MAX_SLIDES);
|
||||||
const sub = heroSub(payload);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -45,7 +43,7 @@ export function Hero() {
|
|||||||
<p className="cp">
|
<p className="cp">
|
||||||
<HeroCatchphrase />
|
<HeroCatchphrase />
|
||||||
</p>
|
</p>
|
||||||
{sub && <p className="tagline">{sub}</p>}
|
<HeroRotator className="tagline" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{isSectionEnabled(payload, 'weather') && (
|
{isSectionEnabled(payload, 'weather') && (
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import {PlaceCategory} from '@o2o/shared';
|
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 {useSite} from '@site/lib/site-context';
|
||||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {Car} from '../paper/Car';
|
import {Car} from '../paper/Car';
|
||||||
import {BOOK_EVENT} from '../paper/Rooms';
|
import {BOOK_EVENT} from '../paper/Rooms';
|
||||||
import {Weather} from './Weather';
|
import {Weather} from './Weather';
|
||||||
@ -19,7 +18,6 @@ export function Hero() {
|
|||||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||||
const lodging = place.category === PlaceCategory.LODGING;
|
const lodging = place.category === PlaceCategory.LODGING;
|
||||||
const region = place.addressLocality ?? place.addressRegion;
|
const region = place.addressLocality ?? place.addressRegion;
|
||||||
const sub = heroSub(payload);
|
|
||||||
const booking = bookingLinks(payload)[0];
|
const booking = bookingLinks(payload)[0];
|
||||||
const stay = lodging ? stayBookingView(payload) : null;
|
const stay = lodging ? stayBookingView(payload) : null;
|
||||||
|
|
||||||
@ -57,7 +55,7 @@ export function Hero() {
|
|||||||
) : (
|
) : (
|
||||||
fixed && <p className="hl in">{fixed}</p>
|
fixed && <p className="hl in">{fixed}</p>
|
||||||
)}
|
)}
|
||||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
<HeroRotator className="tagline" />
|
||||||
<div className="hero-cta">
|
<div className="hero-cta">
|
||||||
{stay ? (
|
{stay ? (
|
||||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import {PlaceCategory} from '@o2o/shared';
|
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 {useSite} from '@site/lib/site-context';
|
||||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||||
import {heroSub} from '../kit/heroSub';
|
|
||||||
import {Car} from '../paper/Car';
|
import {Car} from '../paper/Car';
|
||||||
import {BOOK_EVENT} from '../paper/Rooms';
|
import {BOOK_EVENT} from '../paper/Rooms';
|
||||||
import {Weather} from './Weather';
|
import {Weather} from './Weather';
|
||||||
@ -18,7 +17,6 @@ export function Hero() {
|
|||||||
.slice(0, MAX_SLIDES);
|
.slice(0, MAX_SLIDES);
|
||||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||||
const lodging = place.category === PlaceCategory.LODGING;
|
const lodging = place.category === PlaceCategory.LODGING;
|
||||||
const sub = heroSub(payload);
|
|
||||||
const region = place.addressLocality ?? place.addressRegion;
|
const region = place.addressLocality ?? place.addressRegion;
|
||||||
const booking = bookingLinks(payload)[0];
|
const booking = bookingLinks(payload)[0];
|
||||||
const stay = lodging ? stayBookingView(payload) : null;
|
const stay = lodging ? stayBookingView(payload) : null;
|
||||||
@ -57,7 +55,7 @@ export function Hero() {
|
|||||||
) : (
|
) : (
|
||||||
fixed && <p className="hl in">{fixed}</p>
|
fixed && <p className="hl in">{fixed}</p>
|
||||||
)}
|
)}
|
||||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
<HeroRotator className="tagline" />
|
||||||
<div className="hero-cta">
|
<div className="hero-cta">
|
||||||
{stay ? (
|
{stay ? (
|
||||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||||
|
|||||||
@ -1,37 +1,127 @@
|
|||||||
import {useEffect, useState, type CSSProperties} from 'react';
|
import {useEffect, useLayoutEffect, useRef, useState, type CSSProperties} from 'react';
|
||||||
import {PlaceCategory} from '@o2o/shared';
|
import {PlaceCategory, type Narrative} from '@o2o/shared';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
import lines from '@site/lib/lodging-catchphrases.json';
|
import lines from '@site/lib/lodging-catchphrases.json';
|
||||||
import './hero-catchphrase.css';
|
import './hero-catchphrase.css';
|
||||||
|
|
||||||
export function HeroCatchphrase({children}: {children?: string}) {
|
type Item = NonNullable<Narrative['catchphrases']>['items'][number];
|
||||||
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]);
|
|
||||||
|
|
||||||
if (!lodging) return children;
|
const ROTATE_MS = 6000;
|
||||||
const text = phrases[index % phrases.length];
|
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 (
|
return (
|
||||||
<span className="hero-catchphrase">
|
<span className="hero-catchphrase">
|
||||||
{children && <span className="hero-catchphrase-fixed">{children}</span>}
|
{text.split(/\s+/).map((word, i) => (
|
||||||
<span className="w4d-line w4d-line--in" key={index}>
|
<span key={i}>
|
||||||
{text.split(/\s+/).map((word, i) => (
|
{i > 0 && ' '}
|
||||||
<span key={i}>{i > 0 && ' '}<span className="w4d-w"><span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span></span></span>
|
<span className="w4d-w">
|
||||||
))}
|
<span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span>
|
||||||
</span>
|
</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 { display: block; word-break: keep-all; }
|
||||||
.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 .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; }
|
.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-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 {
|
@keyframes lodging-word-in {
|
||||||
from { opacity: 0; transform: translateY(100%); }
|
from { opacity: 0; transform: translateY(100%); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@keyframes hero-rotator-in {
|
||||||
.hero-catchphrase .w4d-w > span { animation: none; }
|
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 {expect, it} from 'vitest';
|
||||||
import {renderToStaticMarkup} from 'react-dom/server';
|
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 {SiteProvider} from '@site/lib/site-context';
|
||||||
import {LayoutProvider} from '@site/lib/layout';
|
import {LayoutProvider} from '@site/lib/layout';
|
||||||
import {LAYOUTS} from '@site/layouts';
|
import {LAYOUTS} from '@site/layouts';
|
||||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||||
import lines from '@site/lib/lodging-catchphrases.json';
|
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 text = (html: string) => html.replace(/<[^>]+>/g, '');
|
||||||
const html = renderToStaticMarkup(<SiteProvider payload={MOONLIGHT_STAY_PAYLOAD}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>고유문구</HeroCatchphrase></LayoutProvider></SiteProvider>);
|
const render = (payload: SitePayload, node: React.ReactNode) =>
|
||||||
expect(html).toContain('<span class="hero-catchphrase-fixed">고유문구</span>');
|
renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}>{node}</LayoutProvider></SiteProvider>);
|
||||||
expect(html).toContain('w4d-line--in');
|
|
||||||
const text = html.replace(/<[^>]+>/g, '');
|
it('shows the one fixed catchphrase and nothing that rotates in its place', () => {
|
||||||
expect(text).toContain('고유문구' + lines[0]);
|
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);
|
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||||
payload.place.category = PlaceCategory.CAFE;
|
payload.place.category = PlaceCategory.CAFE;
|
||||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>카페 소개</HeroCatchphrase></LayoutProvider></SiteProvider>);
|
expect(render(payload, <HeroCatchphrase>카페 소개</HeroCatchphrase>)).toBe('카페 소개');
|
||||||
expect(html).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', () => {
|
it('ships ten distinct short phrases', () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user