diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 77cdb1c..b41b933 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -276,7 +276,7 @@ COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다. |---|---|---| | 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 | | 2 VISION | `vision_service.run_vision` | 사진 분류 + alt 생성 | -| 3 COPY | `copy_service.run_copy` | 소개문·FAQ (확보된 fact 만 근거) | +| 3 COPY | `copy_service.run_copy` | 소개문·FAQ · 숙박은 첫 화면 캐치프레이즈(fact `tagline`)와 순환 문구(fact `catchphrases`, 한 줄에 하나, `봄 \| ` · `9월 \| ` · `비 \| ` 머리) (확보된 fact 만 근거) | | 4 BUILD | `build_service.run_build` | ★ 정적 빌드 + 발행 게이트 | | 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) | | 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 | diff --git a/docs/GENERATION_FLOW.md b/docs/GENERATION_FLOW.md index 78fdb27..99fa5f4 100644 --- a/docs/GENERATION_FLOW.md +++ b/docs/GENERATION_FLOW.md @@ -8,7 +8,7 @@ ↓ 새로고침 ──────────────────────→ GET /v1/job/{jobId} ↑ -COPY 워커: prepare → generate → save → faq_fill +COPY 워커: prepare → generate → save → catchphrase(숙박만) → faq_fill 각 단계 진입·완료 → jobs.progress(JSONB) ↓ 화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor diff --git a/docs/TEMPLATE_DESIGN.md b/docs/TEMPLATE_DESIGN.md index b722e69..37fa678 100644 --- a/docs/TEMPLATE_DESIGN.md +++ b/docs/TEMPLATE_DESIGN.md @@ -49,6 +49,14 @@ - 같은 목록의 카드는 같은 크기: 사진은 고정 비율 + `object-fit:cover`, 글은 줄 수 말줄임 - 칸 수는 항목 수에 맞춘다(`repeat(auto-fit, minmax(…))`). 3개인데 4칸을 잡아 빈칸을 남기지 않는다 +### 사진 카드는 하나다 — `layouts/kit/Card.tsx` `PhotoCard` + +- 주변 안내 · 축제 · 명소 · 인물(사진 있을 때)은 모두 `PhotoCard` 를 쓴다. 템플릿마다 카드를 새로 짓지 않는다 +- 모양: 사진(3:2) 아래에 제목 1줄 · 거리나 날짜 1줄 · 설명 2줄. 템플릿별로 다른 것은 `--kc-*` 변수만 + (`--kc-r` 사진 모서리, `--kc-tf`·`--kc-tw` 제목 글꼴·굵기, `--kc-mc` 거리 색, 상자형은 `--kc-bg`·`--kc-box-r`) +- 사진 없는 목록(맛집 · 홈 가까운 곳 목록)은 행(row)으로 쓴다. 한 섹션 안에서 카드와 행을 섞지 않는다 +- 모바일 캐러셀 화살표는 숨긴다(카드 위를 가린다). 1024px 이상에서만 사진 가운데 높이에 둔다 + ## 4. 섹션 머리 - **제목은 위, 내용은 아래.** 참고한 펜션 사이트들이 모두 이 방식이다 @@ -77,6 +85,13 @@ ## 6. 값 보여주기 +- **첫 화면은 캐치프레이즈 한 줄(고정) → 그 아래 순환 문구.** `/s/stay` 목업과 같다(`sections/HeroCatchphrase.tsx`) + - 캐치프레이즈 = `narrative.tagline`. 없으면 공통 문구 10개 중 사이트마다 정해진 하나 + - 순환 = `narrative.catchphrases`, 일반 → 계절 → 일반 → 월 → 일반 → 날씨 순서, 6초. 없으면 줄을 두지 않는다 + - 두 값은 COPY 잡이 소개문과 함께 만든다(fact `tagline` · `catchphrases`, `services/copy_steps.py`) +- 첫 화면에 태그·칩(‘주차 가능’, 지역 말풍선)을 두지 않는다 +- 부티크 첫 화면은 모바일도 문(아치) 모양이다. 전체화면 사진으로 되돌리지 않는다 + - **‘가능 · 있음’은 ✓, ‘불가 · 없음’은 ✕ 목록으로 쓴다.** `주차 / 가능` 같은 표로 쓰지 않는다 - 마크업: `
{lodging ?
{fixed}
} +AROUND
걸어서 닿는 명소와 맛집입니다.
-걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.
{english}
}{locality}
} - {fixed &&{fixed}
}
{fixed}
)} - {lodging && fixed &&{fixed}
} - {chips.length > 0 && ( -{fixed}
} +{fixed}
)} - {lodging && fixed &&{fixed}
} - {chips.length > 0 && ( -- {item.name} {item.role && {item.role}} -
- {item.oneLine &&{item.oneLine}
} -+ {item.name} {item.role && {item.role}} +
+ {item.oneLine &&{item.oneLine}
} +{place.description}
} -{fixed}
)} - {lodging && sub &&{sub}
} - {chips.length > 0 && ( -{place.description}
} -{fixed}
)} - {lodging && fixed &&{fixed}
} - {chips.length > 0 && ( -+ + {cycle[index % cycle.length]} + +
+ ); +} diff --git a/solution/site/src/sections/WeatherSection.tsx b/solution/site/src/sections/WeatherSection.tsx index 00da37f..cffea81 100644 --- a/solution/site/src/sections/WeatherSection.tsx +++ b/solution/site/src/sections/WeatherSection.tsx @@ -1,8 +1,8 @@ /** 오늘의 날씨. */ import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react'; -import type {WeatherSky} from '@o2o/shared'; +import type {WeatherSky, WeatherSnapshot} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; -import {useLiveWeather} from '@site/lib/use-live-weather'; +import {useLiveWeather, type LiveWeather} from '@site/lib/use-live-weather'; import {useWeatherNotes} from '@site/lib/use-weather-note'; import {weatherBand, weatherMood} from '@site/lib/derive'; import {Card, Section} from '@site/lib/ui'; @@ -16,6 +16,20 @@ function observedText(iso: string | undefined): string | undefined { return `${Number(month)}월 ${Number(day)}일 ${hour}:${minute} 관측`; } +/** 하늘 문구는 아홉 갈래(WeatherSky), 기온 문구는 기온대로 고른다. */ +export function weatherLineSources(weather: LiveWeather | undefined, notes: WeatherSnapshot | undefined) { + const mood = weatherMood(weather?.condition ?? ''); + const band = weatherBand(weather?.temperature ?? 0); + const condition = (weather?.condition ?? '') as WeatherSky; + const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood; + return { + sky: notes?.noteSets?.[skyKey], + skyFallback: notes?.notes?.[mood] ?? weather?.note, + temp: notes?.tempNoteSets?.[band], + tempFallback: notes?.tempNotes?.[band], + }; +} + export function WeatherSection() { const payload = useSite(); const weather = useLiveWeather({ @@ -27,15 +41,9 @@ export function WeatherSection() { const mood = weatherMood(weather?.condition ?? ''); const band = weatherBand(weather?.temperature ?? 0); - const notes = payload.local.weather; - // 하늘 문구는 아홉 갈래로 고른다(WeatherSky). - const condition = (weather?.condition ?? '') as WeatherSky; - const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood; + const sources = weatherLineSources(weather, payload.local.weather); /* 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */ - const [line, bandLine] = useWeatherNotes( - notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note, - notes?.tempNoteSets?.[band], notes?.tempNotes?.[band], - ); + const [line, bandLine] = useWeatherNotes(sources.sky, sources.skyFallback, sources.temp, sources.tempFallback); if (!weather) return null; const observed = observedText(weather.observedAt); const SkyIcon = MOOD_ICON[mood]; diff --git a/solution/site/src/sections/hero-catchphrase.css b/solution/site/src/sections/hero-catchphrase.css index 2d6b627..74da5c2 100644 --- a/solution/site/src/sections/hero-catchphrase.css +++ b/solution/site/src/sections/hero-catchphrase.css @@ -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; } } diff --git a/solution/site/src/sections/hero-catchphrase.test.tsx b/solution/site/src/sections/hero-catchphrase.test.tsx index 7613179..3305d99 100644 --- a/solution/site/src/sections/hero-catchphrase.test.tsx +++ b/solution/site/src/sections/hero-catchphrase.test.tsx @@ -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(