merge: fix/cards-hero-weather(사진 카드·가운데 창·첫 화면 캐치프레이즈·날씨 HTML 제외) 을 main 에 병합

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-10-01 14:51:30 +09:00
commit ef6f2a1068
63 changed files with 949 additions and 408 deletions

View File

@ -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 모듈이 붙을 때 |

View File

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

View File

@ -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`)
- 첫 화면에 태그·칩(‘주차 가능’, 지역 말풍선)을 두지 않는다
- 부티크 첫 화면은 모바일도 문(아치) 모양이다. 전체화면 사진으로 되돌리지 않는다
- **‘가능 · 있음’은 ✓, ‘불가 · 없음’은 ✕ 목록으로 쓴다.** `주차 / 가능` 같은 표로 쓰지 않는다
- 마크업: `<ul class="amen"><li class="on"><i class="ic">✓</i><span><b>주차</b> 가능</span></li>`
- 색: ✓ `#15803d`, ✕ `#c2410c`. 가능한 것을 먼저 놓는다
@ -91,6 +106,7 @@
- 같은 동작은 한 이름: 예약 시트를 여는 버튼은 모두 **‘예약하기’**, 이용안내 탭으로 가는 버튼은 **‘이용안내 보기’**
- 버튼 글자가 두 줄로 접히면 안 된다. 모바일에서 버튼이 3개 이상이면 2열 격자로, 남는 하나는 한 줄 전체
- 키보드 포커스와 글자 선택색은 템플릿 색으로(`kit/kit.css`)
- 상세·예약 창(`paper/Sheet.tsx`)은 모든 폭에서 **화면 가운데** 뜬다. 폭 560px 이하, 모서리는 템플릿의 `--k-r`
## 8. 하지 않는 것

View File

@ -24,6 +24,8 @@
{ "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" },
{ "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "tagline", "label": "캐치프레이즈", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "catchphrases", "label": "첫 화면 순환 문구", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
{ "key": "standard_capacity", "label": "기준 인원", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "명" },

View File

@ -0,0 +1,43 @@
"""순환 문구를 fact 한 칸에 담는 글 형식 — 사장님이 빌더에서 줄 단위로 고칠 수 있게 JSON 이 아니라 글로 둔다.
한 줄에 한 문구. 앞에 `봄 | ` · `9월 | ` · `비 | ` 를 붙이면 계절·월·날씨 문구, 없으면 일반 문구다.
"""
import re
SEASONS = ("봄", "여름", "가을", "겨울")
WEATHERS = ("맑음", "흐림", "비", "눈")
_MONTH = re.compile(r"^(1[0-2]|[1-9])월$")
def encode(items: list[dict]) -> str:
lines = []
for item in items:
text = str(item.get("text") or "").strip()
if not text:
continue
kind = item.get("kind") or "general"
tag = {"season": item.get("season"), "month": f"{item.get('month')}월", "weather": item.get("weather")}.get(kind)
lines.append(f"{tag} | {text}" if tag else text)
return "\n".join(lines)
def decode(value: str) -> list[dict]:
items: list[dict] = []
for raw in (value or "").splitlines():
line = raw.strip()
if not line:
continue
tag, sep, rest = line.partition("|")
tag, rest = tag.strip(), rest.strip()
if sep and rest:
if tag in SEASONS:
items.append({"text": rest, "kind": "season", "season": tag})
continue
if tag in WEATHERS:
items.append({"text": rest, "kind": "weather", "weather": tag})
continue
if m := _MONTH.match(tag):
items.append({"text": rest, "kind": "month", "month": int(m.group(1))})
continue
items.append({"text": line, "kind": "general"})
return items

View File

@ -1,11 +1,15 @@
"""COPY 흐름."""
from common.enums import PlaceCategory
from common.logger import LOG
from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs
from services.copy_steps import (
CopyAborted, fill_faqs, generate_catchphrases, generate_copy, prepare_copy, save_catchphrases, save_copy,
)
from services.external import gemini_text
from services.llm import provider
from services.llm.errors import LlmError
from services.job_progress import JobProgress
COPY_STEPS = ("prepare", "generate", "save", "faq_fill")
COPY_STEPS = ("prepare", "generate", "save", "catchphrase", "faq_fill")
async def run_copy(job: dict) -> dict:
@ -34,6 +38,18 @@ async def run_copy(job: dict) -> dict:
async with progress.step("save"):
result = await save_copy(inputs, copy)
if inputs.place.category != PlaceCategory.LODGING.value:
await progress.skip("catchphrase", "not_lodging")
elif copy is None:
await progress.skip("catchphrase", "no_facts" if inputs.ungrounded else "not_configured")
else:
try:
async with progress.step("catchphrase"):
result["catchphrase"] = await save_catchphrases(inputs, await generate_catchphrases(inputs))
except LlmError as ex:
LOG.w(f"[copy] 첫 화면 문구 생성 실패, 공통 문구로 계속: {ex}")
await progress.skip("catchphrase", "generation_failed")
if inputs.catalog is not None:
async with progress.step("faq_fill"):
result["faq_fill"] = await fill_faqs(

View File

@ -21,7 +21,7 @@ from crud.fact_crud import FactCRUD
from crud.faq_crud import FaqCRUD
from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact
from services import faq_fill, place_research
from services import catchphrase_text, faq_fill, place_research
from services.external import gemini_text
from services.llm import provider
from services.fact_service import FactService
@ -248,6 +248,47 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None)
return stat
async def generate_catchphrases(inputs: CopyInputs) -> gemini_text.GeneratedCatchphrases:
active_provider = provider.active()
model = (
external_api_config.openai_text_model if active_provider.__name__.endswith("openai")
else external_api_config.gemini_text_model
)
try:
return await gemini_text.generate_catchphrases(
inputs.place.name, inputs.grounded, records=inputs.records or None, model=model,
)
except gemini_text.GeminiNotConfigured as ex:
raise CopyAborted(str(ex)) from ex
async def save_catchphrases(inputs: CopyInputs, generated: gemini_text.GeneratedCatchphrases) -> dict:
place_id = str(inputs.place.place_id)
stat = {"tagline": False, "catchphrases": 0, "rejected": [list(r) for r in generated.rejected][:20]}
actor = UserInfo(user_id=str(inputs.place.owner_user_id), id="generator", role=1)
service = FactService(_fact_crud, _place_crud)
values = (("tagline", generated.tagline), ("catchphrases", catchphrase_text.encode(generated.items)))
for key, value in values:
spec = inputs.schema.get(key)
if not (value or "").strip() or not (spec and spec.allow_llm):
continue
res = await service.upsert_fact(
actor, place_id,
Req_UpsertFact(
key=key, value=value.strip(),
source_type=SourceType.LLM, source_url=f"llm:{generated.source or external_api_config.gemini_text_model}",
),
)
if not res.result.success:
stat["rejected"].append([key, res.result.desc])
elif key == "tagline":
stat["tagline"] = True
else:
stat["catchphrases"] = len(generated.items)
LOG.i(f"[copy] place={place_id} 캐치프레이즈 {'O' if stat['tagline'] else 'X'} · 순환 {stat['catchphrases']}개")
return stat
async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int:
"""노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다."""
l_err, rows = await DB_SESSION_MNG.execute_lambda(

View File

@ -13,6 +13,7 @@ from services.llm import provider
from services.llm.errors import LlmError
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.prompts import catchphrase as catchphrase_prompt
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
@ -159,6 +160,90 @@ async def generate_copy(
return result
# ── 첫 화면 문구(generate_catchphrases) ─────────────────────────────────
@dataclass
class GeneratedCatchphrases:
tagline: Optional[str] = None
items: list[dict] = field(default_factory=list)
rejected: list[tuple[str, str]] = field(default_factory=list)
source: str = ""
async def generate_catchphrases(
place_name: str,
facts: list[FactInput],
*,
records: Optional[list[str]] = None,
model: Optional[str] = None,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> GeneratedCatchphrases:
"""고정 캐치프레이즈 하나와 순환 문구 모음 — 문구마다 소개문과 같은 근거 검사를 거친다."""
llm = provider.active()
if not llm.is_configured():
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
model = model or llm.DEFAULT_MODEL
grounding = list(facts) + [FactInput(key="place_name", label="상호명", value=place_name)]
prompt = catchphrase_prompt.build_prompt(place_name, facts, records)
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try:
llm_result = await llm.generate(
client, model, prompt=prompt, response_schema=catchphrase_prompt.RESPONSE_SCHEMA,
temperature=0.7, max_retries=max_retries,
)
finally:
if owns_client:
await client.aclose()
parsed = llm_result.json or {}
result = GeneratedCatchphrases(source=f"{llm.__name__.rsplit('.', 1)[-1]}:{model}")
seen: set[str] = set()
def accept(text: object) -> Optional[str]:
line = str(text or "").strip()
if not line or line in seen:
return None
if not (catchphrase_prompt.MIN_LEN <= len(line) <= catchphrase_prompt.MAX_LEN):
result.rejected.append((line, f"길이 {len(line)}자"))
return None
if place_name and place_name in line:
result.rejected.append((line, "상호명 포함"))
return None
ok, reasons = ground_check(line, grounding)
if not ok:
result.rejected.append((line, " / ".join(reasons)))
return None
seen.add(line)
return line
result.tagline = accept(parsed.get("tagline"))
for text in (parsed.get("general") or [])[: catchphrase_prompt.COUNT_GENERAL]:
if line := accept(text):
result.items.append({"text": line, "kind": "general"})
for row in parsed.get("season") or []:
season = (row or {}).get("season")
if season in catchphrase_prompt.SEASONS and (line := accept(row.get("text"))):
result.items.append({"text": line, "kind": "season", "season": season})
for row in parsed.get("month") or []:
month = (row or {}).get("month")
if isinstance(month, int) and 1 <= month <= 12 and (line := accept(row.get("text"))):
result.items.append({"text": line, "kind": "month", "month": month})
for row in parsed.get("weather") or []:
weather = (row or {}).get("weather")
if weather in catchphrase_prompt.WEATHERS and (line := accept(row.get("text"))):
result.items.append({"text": line, "kind": "weather", "weather": weather})
usage = llm_result.usage
LOG.i(
f"[llm-text] '{place_name}' 첫 화면 문구 — 캐치프레이즈 {'O' if result.tagline else 'X'} · "
f"순환 {len(result.items)}개 · 반려 {len(result.rejected)}건 · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
)
return result
# ── 요약(summarize_text) ──────────────────────────────────────────────────
_SUMMARY_CACHE: dict[str, str] = {}
_SUMMARY_CACHE_MAX = 500

View File

@ -0,0 +1,84 @@
"""숙소 첫 화면 문구 — 고정 캐치프레이즈 하나와 그 아래 줄에서 도는 문구 모음."""
from typing import Optional, Sequence
from services.prompts.copy import FactLike, _fact_lines
SEASONS = ("봄", "여름", "가을", "겨울")
WEATHERS = ("맑음", "흐림", "비", "눈")
COUNT_GENERAL = 20
COUNT_PER_SEASON = 3
COUNT_PER_WEATHER = 2
MIN_LEN = 8
MAX_LEN = 30
RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"tagline": {"type": "string"},
"general": {"type": "array", "items": {"type": "string"}},
"season": {
"type": "array",
"items": {
"type": "object",
"properties": {"season": {"type": "string", "enum": list(SEASONS)}, "text": {"type": "string"}},
"required": ["season", "text"],
},
},
"month": {
"type": "array",
"items": {
"type": "object",
"properties": {"month": {"type": "integer"}, "text": {"type": "string"}},
"required": ["month", "text"],
},
},
"weather": {
"type": "array",
"items": {
"type": "object",
"properties": {"weather": {"type": "string", "enum": list(WEATHERS)}, "text": {"type": "string"}},
"required": ["weather", "text"],
},
},
},
"required": ["tagline", "general", "season", "month", "weather"],
}
def build_prompt(
place_name: str,
facts: Sequence[FactLike],
records: Optional[Sequence[str]] = None,
) -> str:
sections = [
f"숙소 '{place_name}' 홈페이지 첫 화면 문구를 쓴다.",
"첫 화면은 숙소 이름 아래 **고정 캐치프레이즈 한 줄**, 그 아래 줄에서 짧은 문구가 몇 초마다 바뀐다.",
"",
"확인된 사실:",
_fact_lines(facts) if facts else "- 없음",
]
if records:
sections.extend(["", "출처가 있는 기록:", *(f"- {line}" for line in records)])
sections.extend([
"",
"출력:",
f"- tagline: 이 숙소만의 캐치프레이즈 1개, {MIN_LEN}~{MAX_LEN}자. 이 집을 한 줄로 알아보게 하는 문장",
f"- general: 계절·날씨와 상관없는 문구 {COUNT_GENERAL}개",
f"- season: 봄·여름·가을·겨울 각 {COUNT_PER_SEASON}개",
"- month: 1월~12월 각 1개",
f"- weather: 맑음·흐림·비·눈 각 {COUNT_PER_WEATHER}개. 그 날씨에 이 집에서 보이고 들리는 것",
f"- 모든 문구는 공백 포함 {MIN_LEN}~{MAX_LEN}자, 한 문장",
"",
"좋은 예(다른 숙소): '백 년 된 집에서 하룻밤' · '담 너머는 히로쓰 가옥입니다' ·"
" '마당 평상에 앉으면 항구 소리가 들립니다' · '이불에서 볕 냄새가 납니다'",
"",
"규칙:",
"- 위 사실과 기록에 있는 것만 쓴다. 없는 시설·경관·숫자·거리·역사를 지어내지 않는다.",
"- 사실이 적으면 머무는 시간·쉼처럼 확인이 필요 없는 말로 쓴다.",
"- 담백하게 쓴다. '최고', '완벽한', '특별한', '힐링' 같은 홍보 수식어와 느낌표를 쓰지 않는다.",
"- 문구끼리 표현이 겹치지 않게 한다. 상호명은 tagline 에도 넣지 않는다.",
"- false·불가·없음 값을 있는 것처럼 쓰지 않는다.",
"- 존댓말 평서문 또는 명사형으로 끝낸다.",
])
return "\n".join(sections)

View File

@ -18,6 +18,7 @@ from common.enums import (
)
from common.logger import LOG
from common.template_catalog import TEMPLATES, industry_of, resolve_template_id
from services import catchphrase_text
from services.intro_summary import summarize_intro
from services.stay_guide import nol_stay_guide
from services.weather_notes import weather_notes
@ -33,6 +34,9 @@ DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads"
SITE_HOST_ENV = "SITE_PUBLIC_HOST"
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
_HERO_COPY_KEYS = {"tagline", "catchphrases"}
def _scheme(host: str) -> str:
"""localhost 는 http 다."""
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
@ -596,6 +600,14 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
# 요약은 **첫 문장**이다.
"summary": _first_sentence(paragraphs[0]) if paragraphs else None,
}
tagline = _text(next((f["value"] for f in place_facts if f["key"] == "tagline" and f["value"]), ""))
if tagline:
narrative["tagline"] = tagline.strip()
rotating = catchphrase_text.decode(
_text(next((f["value"] for f in place_facts if f["key"] == "catchphrases" and f["value"]), ""))
)
if rotating:
narrative["catchphrases"] = {"version": 1, "items": rotating}
theme = _theme(site, category)
@ -651,7 +663,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
),
"kakaoPlaceId": kakao_place_id or None,
},
"facts": place_facts,
"facts": [f for f in place_facts if f["key"] not in _HERO_COPY_KEYS],
"units": units,
"media": media,
"faqs": faqs,

View File

@ -0,0 +1,26 @@
"""순환 문구 글 형식 — 사장님이 줄 단위로 고친 글이 그대로 계절·월·날씨 문구로 읽혀야 한다."""
from services import catchphrase_text
def test_round_trips_every_kind():
items = [
{"text": "백 년 된 집에서 하룻밤", "kind": "general"},
{"text": "봄에는 대문을 열어 둡니다", "kind": "season", "season": "봄"},
{"text": "새해 첫 아침을 이 집에서", "kind": "month", "month": 1},
{"text": "기와에 빗소리가 납니다", "kind": "weather", "weather": "비"},
]
text = catchphrase_text.encode(items)
assert text.splitlines() == [
"백 년 된 집에서 하룻밤",
"봄 | 봄에는 대문을 열어 둡니다",
"1월 | 새해 첫 아침을 이 집에서",
"비 | 기와에 빗소리가 납니다",
]
assert catchphrase_text.decode(text) == items
def test_unknown_tag_stays_a_general_line():
assert catchphrase_text.decode("장마 | 비가 옵니다\n\n 13월 | 없는 달 ") == [
{"text": "장마 | 비가 옵니다", "kind": "general"},
{"text": "13월 | 없는 달", "kind": "general"},
]

View File

@ -30,6 +30,15 @@ def _patch(monkeypatch, copy):
monkeypatch.setattr(gemini_text, "generate_copy", _gen)
async def _phrases(place_name, facts, **kw):
return gemini_text.GeneratedCatchphrases(
tagline="체크인 오후 세 시, 조용한 집",
items=[{"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"},
{"text": "봄에는 창을 열어 둡니다", "kind": "season", "season": "봄"}],
)
monkeypatch.setattr(gemini_text, "generate_catchphrases", _phrases)
async def _place_with_facts(client, h, n_verified=5):
pid = (await client.post("/v1/place", headers=h, json={"name": "카피펜션", "category": 1})).json()["place"]["place_id"]
@ -96,7 +105,7 @@ async def test_resume_reuses_completed_job(auth_headers, client, db_engine, monk
job = (await client.get(f"/v1/job/{first['job_id']}", headers=h)).json()["job"]
assert job["place_id"] == pid
assert [(s["id"], s["status"]) for s in job["progress"]["steps"]] == [
("prepare", "done"), ("generate", "done"), ("save", "done"), ("faq_fill", "done"),
("prepare", "done"), ("generate", "done"), ("save", "done"), ("catchphrase", "done"), ("faq_fill", "done"),
]
fresh = (await client.post(path, headers=h, json={})).json()
assert fresh["job_id"] != first["job_id"]
@ -111,7 +120,7 @@ async def test_copy_progress_is_visible_during_generation(auth_headers, client,
async def generate(*args, **kwargs):
row = await JobQueue().get(started["job_id"])
assert row["status"] == JobStatus.RUNNING.value
assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending"]
assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending", "pending"]
return _copy()
monkeypatch.setattr(gemini_text, "generate_copy", generate)
@ -303,3 +312,21 @@ async def test_copy_requires_api_key(auth_headers, client, monkeypatch):
pid = await _place_with_facts(client, h)
r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
assert r.json()["result"]["code"] == ErrorType.GENERATOR_NOT_CONFIGURED.value
async def test_copy_writes_one_catchphrase_and_the_rotating_lines(auth_headers, client, db_engine, monkeypatch):
_patch(monkeypatch, _copy())
h = await auth_headers("catchphrase-owner")
pid = await _place_with_facts(client, h)
job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"]
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
assert job["result"]["catchphrase"]["tagline"] is True
assert job["result"]["catchphrase"]["catchphrases"] == 2
async with db_engine.begin() as c:
rows = dict((await c.execute(
text("SELECT key, value FROM place_facts WHERE place_id = :p AND key IN ('tagline', 'catchphrases')"),
{"p": uuid.UUID(pid)},
)).all())
assert rows["tagline"] == "체크인 오후 세 시, 조용한 집"
assert rows["catchphrases"] == "짐을 풀면 골목부터 걸어 보세요\n봄 | 봄에는 창을 열어 둡니다"

View File

@ -377,3 +377,34 @@ async def test_summarize_empty_text_returns_none():
"""검증: 빈 문자열."""
result = await gt.summarize_text(" ")
assert result is None
# ── 첫 화면 문구 ───────────────────────────────────────────────────────────
def _phrase_payload(body):
return {
"candidates": [{"content": {"parts": [{"text": json.dumps(body, ensure_ascii=False)}]}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 800, "candidatesTokenCount": 400},
}
async def test_catchphrases_keep_only_grounded_lines_of_the_right_shape():
body = {
"tagline": "주차하고 바로 쉬는 집",
"general": ["짐을 풀면 골목부터 걸어 보세요", "오션뷰 수영장이 있습니다", "짧다", "하조대펜션에서 쉬세요"],
"season": [{"season": "봄", "text": "봄에는 창을 열어 둡니다"}, {"season": "장마", "text": "비가 오래 오는 계절입니다"}],
"month": [{"month": 1, "text": "새해 첫 아침을 조용히"}, {"month": 13, "text": "없는 달의 문구입니다"}],
"weather": [{"weather": "비", "text": "빗소리를 들으며 쉬세요"}],
}
async with _client(_ok(_phrase_payload(body))) as c:
res = await gt.generate_catchphrases("하조대펜션", FACTS, client=c)
assert res.tagline == "주차하고 바로 쉬는 집"
assert res.items == [
{"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"},
{"text": "봄에는 창을 열어 둡니다", "kind": "season", "season": "봄"},
{"text": "새해 첫 아침을 조용히", "kind": "month", "month": 1},
{"text": "빗소리를 들으며 쉬세요", "kind": "weather", "weather": "비"},
]
reasons = dict(res.rejected)
assert "오션뷰 수영장이 있습니다" in reasons
assert reasons["짧다"].startswith("길이")
assert reasons["하조대펜션에서 쉬세요"] == "상호명 포함"

View File

@ -3,6 +3,7 @@ export const GENERATION_LABELS: Record<string, string> = {
prepare: '수집된 사진 분류 및 대체 텍스트 생성',
generate: '브랜드 컬러 시스템 및 타이포그래피 조합',
save: '확인된 사실만으로 소개 콘텐츠 구성',
catchphrase: '첫 화면 캐치프레이즈와 순환 문구 작성',
faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비',
};
@ -10,5 +11,6 @@ export const SKIP_REASONS: Record<string, string> = {
no_facts: '확인된 정보 없음',
not_configured: '생성 서비스 미설정',
no_catalog: '공통 질문 대상 업종 아님',
not_lodging: '숙박 업종만 해당',
generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다',
};

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

@ -46,7 +46,7 @@ it('고택은 섹션 순서와 상관없이 날씨를 첫 화면 바로 아래
payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])];
const html = renderToStaticMarkup(<App payload={payload} />);
const hero = html.indexOf('class="hero"');
const brief = html.indexOf('class="wbrief"');
const brief = html.indexOf('data-editor-id="weather"');
expect(hero).toBeGreaterThan(-1);
expect(brief).toBeGreaterThan(hero);
expect(html.slice(hero, brief)).not.toContain('class="sec');

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
import {Idx} from './SectionHead';
import {Tabs} from './Tabs';
@ -66,6 +67,17 @@ export function Around() {
</li>
);
const spot = (place: LocalPlace) => (
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
desc={place.description}
onClick={() => setOpen(place)}
/>
);
return (
<>
{rail.length > 0 && (
@ -86,16 +98,14 @@ export function Around() {
<section id="walk" aria-labelledby="walk-heading">
<Idx id="walk-heading" title="주변 명소" aside={`${spots.length} Spots`} />
<Tabs items={rangeTabs(spots)} value={spotRange} onChange={setSpotRange} />
<ul className="guide">{shownSpots.slice(0, firstSpots).map((place, i) => item(place, i))}</ul>
<div className="kgrid">{shownSpots.slice(0, firstSpots).map(spot)}</div>
{shownSpots.length > firstSpots && (
<details>
<summary className="line">
<span>{shownSpots.length - firstSpots}곳 더 보기</span>
<span>+</span>
</summary>
<ul className="guide">
{shownSpots.slice(firstSpots).map((place, i) => item(place, i + firstSpots))}
</ul>
<div className="kgrid">{shownSpots.slice(firstSpots).map(spot)}</div>
</details>
)}
</section>

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Car} from '../paper/Car';
import {Sheet} from '../paper/Sheet';
import {Idx} from './SectionHead';
@ -34,17 +35,12 @@ export function Festival() {
arrows
label="계절별 축제"
slides={shown.map((f) => (
<button type="button" className="fc" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<span className="ph">
<img src={f.imageUrl} alt={f.name} loading="lazy" />
</span>
) : (
<span className="noimg">{f.name}</span>
)}
<span className="t">{f.name}</span>
<span className="d">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
<PhotoCard
image={f.imageUrl}
title={f.name}
meta={[f.month, f.location].filter(Boolean).join(' · ')}
onClick={() => setOpen(f)}
/>
))}
/>
</div>

View File

@ -1,5 +1,5 @@
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 {BOOK_EVENT} from '../paper/Rooms';
@ -29,7 +29,7 @@ export function Hero() {
<span className="no" /> 오늘의 문장
</p>
{(lodging || fixed) && <p className="rot">{lodging ? <HeroCatchphrase /> : fixed}</p>}
{lodging && fixed && <p className="m sub">{fixed}</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

@ -284,3 +284,7 @@
.w4-bigtype .ppl{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.w4-bigtype .amen{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}}
.w4-bigtype .lead+:is(.chips,.tabrow){margin-top:20px}
.w4-bigtype{--kc-r:0;--kc-tw:500;--kc-ts:17px;--kc-mc:var(--sub);--kc-sub:var(--sub);--kc-px:12px;--kc-pb:14px}
.w4-bigtype .kgrid{gap:0;margin-top:0;border-top:1px solid var(--line);width:calc(100% + 1px);clip-path:inset(0 1px 0 0)}
.w4-bigtype .kgrid>.kcard{border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
@media (min-width:1024px){.w4-bigtype .kgrid{grid-template-columns:repeat(4,minmax(0,1fr));gap:0}}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
const WALK = 80;
@ -70,6 +71,17 @@ export function Around() {
</button>
);
const spot = (item: LocalPlace) => (
<PhotoCard
key={item.name}
image={item.imageUrl}
title={item.name}
meta={item.distanceText}
desc={item.description}
onClick={() => setOpen(item)}
/>
);
return (
<>
{rail.length > 0 && (
@ -78,7 +90,7 @@ export function Around() {
<p className="label">AROUND</p>
<h2 id="around-heading">주변 안내</h2>
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
<div className="guide">{rail.slice(0, HOME_ROWS).map((item) => place(item))}</div>
<div className="guide guide--card">{rail.slice(0, HOME_ROWS).map(spot)}</div>
<a className="link b-more--c" href="#/area">
지역 소개에서 모두 보기
</a>
@ -95,11 +107,11 @@ export function Around() {
</h2>
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
<div className="guide">{shownSpots.slice(0, firstSpots).map((item) => place(item))}</div>
<div className="guide guide--card">{shownSpots.slice(0, firstSpots).map(spot)}</div>
{shownSpots.length > firstSpots && (
<details className="fold" key={spotRange}>
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary>
<div className="guide">{shownSpots.slice(firstSpots).map((item) => place(item))}</div>
<div className="guide guide--card">{shownSpots.slice(firstSpots).map(spot)}</div>
</details>
)}
</section>

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
const SEASONS = ['봄', '여름', '가을', '겨울'];
@ -40,15 +41,13 @@ export function Festival() {
)}
<div className="rail">
{shown.map((f) => (
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" />
) : (
<span className="noimg">{f.name}</span>
)}
<b className="nm">{f.name}</b>
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
<PhotoCard
key={f.name}
image={f.imageUrl}
title={f.name}
meta={[f.month, f.location].filter(Boolean).join(' · ')}
onClick={() => setOpen(f)}
/>
))}
</div>
</section>

View File

@ -1,5 +1,5 @@
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';
@ -26,7 +26,6 @@ export function Hero() {
}, 5000);
return () => window.clearInterval(timer);
}, [total]);
const fixed = narrative.tagline ?? narrative.heroSubline;
const english = place.englishName?.trim();
const locality = [place.addressLocality, place.addressRegion].find(Boolean);
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
@ -57,10 +56,10 @@ export function Hero() {
{english && <p className="b-hero-en">{english}</p>}
<h1 className={english ? 'b-hero-name' : 'b-hero-name b-hero-name--solo'}>{place.name}</h1>
{locality && <p className="b-hero-loc">{locality}</p>}
{fixed && <p className="b-hero-ko">{fixed}</p>}
<p className="b-hero-cp">
<HeroCatchphrase />
</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

@ -345,3 +345,22 @@
.w4-boutique[data-page=story] main .b-rule{margin-left:0;margin-right:0}
.w4-boutique[data-page=story] main .shell{padding-inline:24px}
.w4-boutique[data-page=story] main #reviews{margin-top:var(--sec)}}
.w4-boutique{--kc-r:999px 999px 0 0;--kc-ar:4/3;--kc-tf:var(--mj);--kc-tw:400;--kc-ts:17px;--kc-mc:var(--rose);--kc-sub:var(--sub);--kc-soft:var(--bg2)}
.w4-boutique .rail>.kcard{flex:0 0 62%;height:auto}
.w4-boutique .guide--card{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:28px 14px}
.w4-boutique #around .guide--card{grid-template-columns:repeat(3,minmax(0,1fr));gap:20px 10px}
.w4-boutique #around .kcard-t{font-size:15px}
@media (min-width:1024px){.w4-boutique .rail>.kcard{flex-basis:calc((100% - 48px) / 3)}.w4-boutique .guide--card{grid-template-columns:repeat(3,minmax(0,1fr));gap:36px 24px}}
@media (max-width:1023px){
.w4-boutique{--aw:min(250px,calc(100vw - 88px));--ah:calc(var(--aw) * 1.12)}
.w4-boutique[data-page=home] .b-hd:not(.solid){color:var(--brown)}
.w4-boutique .b-hero{height:auto;min-height:0;padding:calc(84px + var(--ah) + 24px) var(--g) 8px;background:transparent;overflow:visible}
.w4-boutique .b-hero:before{content:"";position:absolute;top:100px;left:50%;width:var(--aw);height:var(--ah);margin-left:calc(var(--aw) / -2 + 14px);border-radius:999px 999px 0 0;background:var(--bg2)}
.w4-boutique .b-hero:after{display:none}
.w4-boutique .b-slide{inset:84px auto auto 50%;width:var(--aw);height:var(--ah);margin-left:calc(var(--aw) / -2);border-radius:999px 999px 0 0;overflow:hidden;z-index:1}
.w4-boutique .b-hero-copy{position:relative;left:auto;right:auto;bottom:auto;padding:0;color:var(--brown)}
.w4-boutique .b-hero-ko{color:var(--ink)}
.w4-boutique .b-hero-count{top:calc(84px + var(--ah) - 34px);bottom:auto}
.w4-boutique .b-hero--plain{padding-top:120px}
.w4-boutique .b-hero--plain:before{display:none}
}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide';
@ -71,6 +72,17 @@ export function Around() {
</button>
);
const spot = (item: LocalPlace) => (
<PhotoCard
key={item.name}
image={item.imageUrl}
title={item.name}
meta={item.distanceText}
desc={item.description}
onClick={() => setOpen(item)}
/>
);
return (
<>
{rail.length > 0 && (
@ -81,11 +93,13 @@ export function Around() {
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
<div className="grid3">
{rail.slice(0, HOME_ROWS).map((item) => (
<button key={item.name} type="button" className="card" onClick={() => setOpen(item)}>
<img src={item.imageUrl} alt={item.name} loading="lazy" />
<b className="nm clamp">{item.name}</b>
{item.distanceText && <span className="ds">{item.distanceText}</span>}
</button>
<PhotoCard
key={item.name}
image={item.imageUrl}
title={item.name}
meta={item.distanceText}
onClick={() => setOpen(item)}
/>
))}
</div>
<a className="link more" href="#/area">
@ -104,11 +118,11 @@ export function Around() {
</h2>
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
<div className="guide">{shownSpots.slice(0, firstSpots).map((item) => place(item))}</div>
<div className="guide guide--card">{shownSpots.slice(0, firstSpots).map(spot)}</div>
{shownSpots.length > firstSpots && (
<details className="fold">
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary>
<div className="guide">{shownSpots.slice(firstSpots).map((item) => place(item))}</div>
<div className="guide guide--card">{shownSpots.slice(firstSpots).map(spot)}</div>
</details>
)}
</section>

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
const SEASONS = ['봄', '여름', '가을', '겨울'];
@ -40,15 +41,13 @@ export function Festival() {
)}
<div className="rail">
{shown.map((f) => (
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" />
) : (
<span className="noimg">{f.name}</span>
)}
<b className="nm">{f.name}</b>
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
<PhotoCard
key={f.name}
image={f.imageUrl}
title={f.name}
meta={[f.month, f.location].filter(Boolean).join(' · ')}
onClick={() => setOpen(f)}
/>
))}
</div>
</section>

View File

@ -1,5 +1,5 @@
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';
@ -56,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

@ -296,3 +296,9 @@
.w4-cinema .unit .btns{grid-template-columns:1fr 1fr}}
.w4-cinema .wrap>.label:has(+h2),.w4-cinema .book .label,.w4-cinema .rhead>.kicker{display:none}
.w4-cinema :is(h1,h2,.big,.hl){font-family:var(--tpl-font-heading)}
.w4-cinema{--kc-r:14px;--kc-tw:600;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:rgba(244,239,230,.06)}
.w4-cinema .rail>.kcard{flex:0 0 72%;height:auto}
.w4-cinema .guide--card{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 14px;border-top:0}
.w4-cinema .grid3:has(>.kcard){gap:20px 10px}
.w4-cinema .grid3>.kcard .kcard-t{font-size:15px}
@media (min-width:1024px){.w4-cinema .rail>.kcard{flex:0 0 calc((100% - 30px) / 4)}.w4-cinema .guide--card,.w4-cinema #walk .guide{grid-template-columns:repeat(4,minmax(0,1fr));gap:32px 16px}.w4-cinema #around .grid3:has(>.kcard){grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide';
@ -66,18 +67,14 @@ export function Around() {
);
const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
{place.imageUrl && (
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
)}
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
desc={place.description}
onClick={() => setOpen(place)}
/>
);
return (
@ -94,15 +91,13 @@ export function Around() {
</div>
<div className="guide guide--3">
{rail.slice(0, railMax).map((place) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
</div>
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
onClick={() => setOpen(place)}
/>
))}
</div>
<a href="#/area" className="more">

View File

@ -1,7 +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, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Weather} from './Weather';
@ -20,7 +20,6 @@ export function Hero() {
const region = place.addressLocality ?? place.addressRegion;
const booking = bookingLinks(payload)[0];
const stay = lodging ? stayBookingView(payload) : null;
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
return (
<>
@ -56,16 +55,7 @@ export function Hero() {
) : (
fixed && <p className="hl in">{fixed}</p>
)}
{lodging && fixed && <p className="tagline">{fixed}</p>}
{chips.length > 0 && (
<div className="chips">
{chips.map((chip) => (
<span key={chip} className="chip">
{chip}
</span>
))}
</div>
)}
<HeroRotator className="tagline" />
<div className="hero-cta">
{stay ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon,
} as const;
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
function observed(iso: string): string | undefined {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
}
export function Weather() {

View File

@ -447,3 +447,5 @@
.w4-coral #about .amen li.off .ic{color:#c2410c}
.w4-coral #about .amen li.off{color:var(--sub)}
.w4-coral :is(h1,h2,h3){font-family:var(--tpl-font-heading)}
.w4-coral{--kc-r:0;--kc-tw:500;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:var(--alt)}
.w4-coral .cards2,.w4-coral .guide--3{row-gap:24px}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
import {SpotIcon} from './Art';
import {useWide} from './useWide';
@ -70,6 +71,18 @@ export function Around() {
</button>
);
const spot = (place: LocalPlace) => (
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
desc={place.description}
fallback={<SpotIcon food={false} />}
onClick={() => setOpen(place)}
/>
);
return (
<>
<div data-pg="home">
@ -103,11 +116,11 @@ export function Around() {
const shown = spots.filter((p) => inRange(spotRange)(meters(p)));
return (
<>
<div className="guide guide--card">{shown.slice(0, firstSpots).map((p, i) => row(p, i))}</div>
<div className="guide guide--card">{shown.slice(0, firstSpots).map(spot)}</div>
{shown.length > firstSpots && (
<details>
<summary className="more">{shown.length - firstSpots}곳 더 보기</summary>
<div className="guide guide--card">{shown.slice(firstSpots).map((p, i) => row(p, i + firstSpots))}</div>
<div className="guide guide--card">{shown.slice(firstSpots).map(spot)}</div>
</details>
)}
</>

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
const SEASONS = ['봄', '여름', '가을', '겨울'];
@ -40,15 +41,13 @@ export function Festival() {
)}
<div className="rail">
{shown.map((f) => (
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" />
) : (
<span className="noimg">{f.name}</span>
)}
<b className="nm">{f.name}</b>
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
</button>
<PhotoCard
key={f.name}
image={f.imageUrl}
title={f.name}
meta={[f.month, f.location].filter(Boolean).join(' · ')}
onClick={() => setOpen(f)}
/>
))}
</div>
</section>

View File

@ -1,15 +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 {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]);
@ -32,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({
@ -101,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 fixed = narrative.tagline ?? narrative.heroSubline;
const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const link = bookingLinks(payload)[0];
@ -111,7 +89,6 @@ export function Hero() {
<section className="scene" id="top" data-pg="home" aria-labelledby="hero-heading">
<Sky />
<div className="hero-copy">
{region && <span className="tag">{region}</span>}
<h1 id="hero-heading" className="disp">
<Pop text={place.name} />
</h1>
@ -120,7 +97,7 @@ export function Hero() {
<Pop text={phrase} />
</p>
)}
{fixed && <p className="sub">{fixed}</p>}
<HeroRotator className="sub" />
{(stay || link || place.phone) && (
<div className="hgo">
{stay ? (

View File

@ -334,3 +334,7 @@
@media(max-width:1023px){.w4-graphic #location .btns{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.w4-graphic #location .btns>:first-child{grid-column:1/-1}
.w4-graphic #location .btns>*{white-space:nowrap;min-width:0}}
.w4-graphic{--kc-r:0;--kc-box-r:22px;--kc-bg:var(--card);--kc-shadow:0 2px 0 rgba(29,43,36,.08);--kc-px:12px;--kc-pb:14px;--kc-tf:var(--k-head);--kc-tw:400;--kc-ts:17px;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:#fff1c9}
.w4-graphic .rail>.kcard{flex:0 0 72%;height:auto}
.w4-graphic .guide--card{grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 14px}
@media (min-width:1024px){.w4-graphic .rail>.kcard{flex:0 0 calc((100% - 48px) / 4)}.w4-graphic .guide--card{grid-template-columns:repeat(4,minmax(0,1fr));gap:32px 16px}}

View File

@ -0,0 +1,31 @@
import type {ReactNode} from 'react';
import './card.css';
export function PhotoCard({
image,
title,
meta,
desc,
fallback,
ratio,
onClick,
}: {
image?: string | null;
title: string;
meta?: string | null;
desc?: string | null;
fallback?: ReactNode;
ratio?: string;
onClick: () => void;
}) {
return (
<button type="button" className="kcard" onClick={onClick}>
<span className="kcard-ph" style={ratio ? {aspectRatio: ratio} : undefined}>
{image ? <img src={image} alt={title} loading="lazy" /> : <span className="kcard-no">{fallback ?? title}</span>}
</span>
<span className="kcard-t">{title}</span>
{meta && <span className="kcard-m">{meta}</span>}
{desc && <span className="kcard-d">{desc}</span>}
</button>
);
}

View File

@ -0,0 +1,13 @@
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard{display:flex;flex-direction:column;width:100%;height:100%;min-width:0;margin:0;padding:0 0 var(--kc-pb,0);border:0;border-radius:var(--kc-box-r,0);overflow:hidden;background:var(--kc-bg,none);box-shadow:var(--kc-shadow,none);color:inherit;font:inherit;text-align:left;cursor:pointer;-webkit-appearance:none;appearance:none}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard-ph{position:relative;display:block;width:100%;aspect-ratio:var(--kc-ar,3/2);border-radius:var(--kc-r,14px);overflow:hidden;background:var(--kc-soft,rgba(127,127,127,.1))}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard-ph img{display:block;width:100%;height:100%;object-fit:cover;transition:transform .5s ease}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard:hover .kcard-ph img{transform:scale(1.03)}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard-no{display:grid;place-items:center;width:100%;height:100%;padding:0 16px;font-family:var(--kc-tf,inherit);font-size:17px;line-height:1.4;text-align:center;color:var(--kc-sub,inherit)}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard-no svg{width:40px;height:40px}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard-t{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;margin-top:12px;padding:0 var(--kc-px,2px);font-family:var(--kc-tf,inherit);font-size:var(--kc-ts,16px);font-weight:var(--kc-tw,600);line-height:1.45;letter-spacing:-.01em}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard-m{display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;margin-top:4px;padding:0 var(--kc-px,2px);font-size:14px;font-weight:500;line-height:1.5;color:var(--kc-mc,inherit)}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kcard-d{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:6px;padding:0 var(--kc-px,2px);font-size:15px;line-height:1.6;color:var(--kc-sub,inherit)}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 14px}
@media (min-width:1024px){:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .kgrid{grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:32px 20px}}
:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .car--multi:has(.kcard) .arrow{top:calc(50% - 30px)}
@media (max-width:1023px){:is(.w4p,.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .car--multi:has(.kcard) .arrow{display:none}}

View File

@ -1,8 +1,8 @@
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine){--k-pad:20px;--k-wide:640px;--k-r:12px;--k-soft:rgba(127,127,127,.08);--k-line:rgba(127,127,127,.22);--k-head:inherit}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) *{box-sizing:border-box}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{border-radius:20px 20px 0 0}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform{padding:22px var(--k-pad) 8px}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform h3{margin:0 0 16px;font-family:var(--k-head);font-size:21px;font-weight:600}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{border-radius:var(--k-r)}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) :is(.sform,.sheetbody){padding:22px var(--k-pad) 8px}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) :is(.sform,.sheetbody) h3{margin:0 0 16px;font-family:var(--k-head);font-size:21px;font-weight:600}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet .cta,:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform button.cta{display:flex;align-items:center;justify-content:center;width:100%;height:52px;border:0;border-radius:var(--k-r);background:var(--k-ink);color:var(--k-bg);font:inherit;font-size:16px;font-weight:700;cursor:pointer}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform input,:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform textarea{width:100%;padding:12px 14px;border:1px solid var(--k-line);border-radius:var(--k-r);background:transparent;color:var(--k-ink);font:inherit;font-size:16px}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .car{position:relative}
@ -48,10 +48,10 @@
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .car .dots button.on{width:18px;border-radius:999px;background:var(--k-ink)}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) details>summary{list-style:none;cursor:pointer}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) details>summary::-webkit-details-marker{display:none}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet{position:fixed;inset:0;z-index:40}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:24px 16px}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet[hidden]{display:none}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbg{position:absolute;inset:0;background:rgba(14,13,10,.52)}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{position:absolute;left:0;right:0;bottom:0;max-width:var(--k-wide);margin:0 auto; max-height:88svh;overflow:auto;background:var(--k-bg);padding:0 0 32px; -webkit-overflow-scrolling:touch}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{position:relative;width:100%;max-width:560px;margin:0 auto; max-height:calc(100svh - 48px);overflow:auto;background:var(--k-bg);padding:0 0 32px; -webkit-overflow-scrolling:touch}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sx{position:sticky;top:0;float:right;width:48px;height:48px;border:0;background:none; font-size:22px;line-height:1;color:var(--k-ink);cursor:pointer;z-index:1}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sin{padding-top:16px}
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sin img{margin:0 0 18px}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide';
@ -66,18 +67,14 @@ export function Around() {
);
const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
{place.imageUrl && (
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
)}
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
desc={place.description}
onClick={() => setOpen(place)}
/>
);
return (
@ -94,15 +91,13 @@ export function Around() {
</div>
<div className="guide guide--3">
{rail.slice(0, railMax).map((place) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
</div>
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
onClick={() => setOpen(place)}
/>
))}
</div>
<a href="#/area" className="more">

View File

@ -1,7 +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, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Weather} from './Weather';
@ -20,7 +20,6 @@ export function Hero() {
const region = place.addressLocality ?? place.addressRegion;
const booking = bookingLinks(payload)[0];
const stay = lodging ? stayBookingView(payload) : null;
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
return (
<>
@ -56,16 +55,7 @@ export function Hero() {
) : (
fixed && <p className="hl in">{fixed}</p>
)}
{lodging && fixed && <p className="tagline">{fixed}</p>}
{chips.length > 0 && (
<div className="chips">
{chips.map((chip) => (
<span key={chip} className="chip">
{chip}
</span>
))}
</div>
)}
<HeroRotator className="tagline" />
<div className="hero-cta">
{stay ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon,
} as const;
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
function observed(iso: string): string | undefined {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
}
export function Weather() {

View File

@ -450,3 +450,5 @@
.w4-minimal #about .amen li.off .ic{color:#c2410c}
.w4-minimal #about .amen li.off{color:var(--sub)}
.w4-minimal :is(h1,h2,.hl){font-family:var(--tpl-font-heading)}
.w4-minimal{--kc-r:0;--kc-tw:400;--kc-mc:var(--sub);--kc-sub:var(--sub);--kc-soft:var(--alt)}
.w4-minimal .cards2,.w4-minimal .guide--3{row-gap:28px}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Car} from './Car';
import {Sheet} from './Sheet';
@ -80,11 +81,7 @@ export function Around() {
arrows
label="주변 안내"
slides={rail.map((place) => (
<button type="button" className="itemcard" onClick={() => setOpen(place)}>
<img src={place.imageUrl} alt={place.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
<p className="cap">{place.name}</p>
{place.distanceText && <p className="cap dim">{place.distanceText}</p>}
</button>
<PhotoCard image={place.imageUrl} title={place.name} meta={place.distanceText} onClick={() => setOpen(place)} />
))}
/>
</section>
@ -104,17 +101,16 @@ export function Around() {
걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.
</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
<div className="grid2" style={{gap: 14}}>
<div className="grid2" style={{gap: '24px 14px'}}>
{shownSpots.map((place) => (
<button key={place.name} type="button" className="itemcard" onClick={() => setOpen(place)}>
{place.imageUrl && (
<img src={place.imageUrl} alt={place.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
)}
<p className="cap">
<b>{place.name}</b> {place.distanceText && <span className="dim">{place.distanceText}</span>}
</p>
{place.description && <p className="cap dim clamp">{place.description}</p>}
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
desc={place.description}
onClick={() => setOpen(place)}
/>
))}
</div>
</section>

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Car} from './Car';
import {Sheet} from './Sheet';
@ -48,19 +49,12 @@ export function Festival() {
arrows
label="계절별 축제"
slides={shown.map((f) => (
<button type="button" className="itemcard" onClick={() => setOpen(f)}>
{f.imageUrl ? (
<img src={f.imageUrl} alt={f.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
) : (
<span className="noimg" style={{aspectRatio: '3 / 2'}}>
{f.name}
</span>
)}
<p className="cap">
<b>{f.name}</b>
</p>
<p className="cap dim">{[f.month, f.location].filter(Boolean).join(' · ')}</p>
</button>
<PhotoCard
image={f.imageUrl}
title={f.name}
meta={[f.month, f.location].filter(Boolean).join(' · ')}
onClick={() => setOpen(f)}
/>
))}
/>
</section>

View File

@ -1,4 +1,4 @@
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 {Weather} from './Weather';
@ -13,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 fixed = narrative.tagline ?? narrative.heroSubline;
return (
<>
@ -41,10 +40,10 @@ export function Hero() {
<p className="eyebrow">{place.addressLocality ?? place.addressRegion}</p>
)}
<h1 className="mj">{place.name}</h1>
{fixed && <p className="tagline">{fixed}</p>}
<p className="cp">
<HeroCatchphrase />
</p>
<HeroRotator className="tagline" />
</div>
</section>
{isSectionEnabled(payload, 'weather') && (

View File

@ -2,6 +2,7 @@ import {useState, type ReactNode} from 'react';
import type {ChronicleItem, PeopleItem, ReadingItem, SongItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {PhotoCard} from '../kit/Card';
import {Sheet} from './Sheet';
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'};
@ -60,27 +61,35 @@ export function Story() {
{item.year && <span className="r">{item.year}</span>}
</button>
);
const person = (item: PeopleItem) => (
<button
key={item.name}
type="button"
className="itemcard"
onClick={() =>
const person = (item: PeopleItem) => {
const show = () =>
setOpen({
title: item.name,
meta: [item.role, item.years].filter(Boolean).join(' · '),
body: item.oneLine,
image: item.imageUrl,
})
}
>
{item.imageUrl && <img src={item.imageUrl} alt={item.name} loading="lazy" style={{aspectRatio: '1 / 1'}} />}
});
if (item.imageUrl)
return (
<PhotoCard
key={item.name}
image={item.imageUrl}
title={item.name}
meta={item.role}
desc={item.oneLine}
ratio="1 / 1"
onClick={show}
/>
);
return (
<button key={item.name} type="button" className="itemcard" onClick={show}>
<p className="cap">
<b>{item.name}</b> {item.role && <span className="dim">{item.role}</span>}
</p>
{item.oneLine && <p className="cap dim clamp">{item.oneLine}</p>}
</button>
);
};
const event = (item: ChronicleItem) => (
<button
key={`${item.year}-${item.title}`}

View File

@ -255,10 +255,10 @@
@supports(padding:max(0px)){
.w4p .bar{height:calc(var(--bar) + env(safe-area-inset-bottom)); padding-bottom:calc(9px + env(safe-area-inset-bottom))}
}
.w4p .sheet{position:fixed;inset:0;z-index:40}
.w4p .sheet{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:24px 16px}
.w4p .sheet[hidden]{display:none}
.w4p .sbg{position:absolute;inset:0;background:rgba(14,13,10,.52)}
.w4p .sbox{position:absolute;left:0;right:0;bottom:0;max-width:var(--wide);margin:0 auto; max-height:88svh;overflow:auto;background:var(--paper);padding:0 0 32px; -webkit-overflow-scrolling:touch}
.w4p .sbox{position:relative;width:100%;max-width:560px;margin:0 auto; max-height:calc(100svh - 48px);border-radius:8px;overflow:auto;background:var(--paper);padding:0 0 32px; -webkit-overflow-scrolling:touch}
.w4p .sx{position:sticky;top:0;float:right;width:48px;height:48px;border:0;background:none; font-size:22px;line-height:1;color:var(--ink);cursor:pointer;z-index:1}
.w4p .sin{padding-top:16px}
.w4p .sin img{margin:0 0 18px}
@ -338,3 +338,4 @@
.w4p #w4d-panel .w4d-eq i{background:#8c4a2f}
.w4p #w4d-lyrics-box{padding:14px 16px 18px;border-top:1px solid var(--line);font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:15px;line-height:1.95;color:#39352e}
.w4p #w4d-panel .w4d-info{line-height:1.45}
.w4p{--kc-r:4px;--kc-tw:500;--kc-ts:16.5px;--kc-mc:var(--sub);--kc-sub:var(--sub);--kc-soft:var(--tint)}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide';
@ -66,18 +67,14 @@ export function Around() {
);
const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
{place.imageUrl && (
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
)}
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
desc={place.description}
onClick={() => setOpen(place)}
/>
);
return (

View File

@ -1,7 +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, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Weather} from './Weather';
@ -18,13 +18,8 @@ export function Hero() {
const fixed = narrative.tagline ?? narrative.heroSubline;
const lodging = place.category === PlaceCategory.LODGING;
const region = place.addressLocality ?? place.addressRegion;
const nearest = [...payload.local.attractions]
.filter((spot) => spot.distanceText)
.sort((a, b) => (a.distanceMeters ?? Infinity) - (b.distanceMeters ?? Infinity))[0];
const sub = fixed ?? (nearest ? `${nearest.name}까지 ${nearest.distanceText}` : undefined);
const booking = bookingLinks(payload)[0];
const stay = lodging ? stayBookingView(payload) : null;
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
return (
<>
@ -60,16 +55,7 @@ export function Hero() {
) : (
fixed && <p className="hl in">{fixed}</p>
)}
{lodging && sub && <p className="tagline">{sub}</p>}
{chips.length > 0 && (
<div className="chips">
{chips.map((chip) => (
<span key={chip} className="chip">
{chip}
</span>
))}
</div>
)}
<HeroRotator className="tagline" />
<div className="hero-cta">
{stay ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon,
} as const;
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
function observed(iso: string): string | undefined {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
}
export function Weather() {

View File

@ -450,3 +450,5 @@
.w4-pine #location .loc .map{aspect-ratio:auto;height:100%;min-height:320px}
.w4-pine #location .loc .bd{align-self:center;padding:28px}
.w4-pine #festival .car--multi .slide{flex:0 0 260px}}
.w4-pine{--kc-r:14px;--kc-tw:600;--kc-mc:var(--pine);--kc-sub:var(--sub);--kc-soft:var(--alt)}
.w4-pine .cards2{row-gap:24px}

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide';
@ -66,18 +67,14 @@ export function Around() {
);
const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
{place.imageUrl && (
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
)}
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
desc={place.description}
onClick={() => setOpen(place)}
/>
);
return (
@ -94,15 +91,13 @@ export function Around() {
</div>
<div className="guide guide--3">
{rail.slice(0, railMax).map((place) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
<div className="ph">
<img src={place.imageUrl} alt={place.name} loading="lazy" />
</div>
<div className="bd">
<h3>{place.name}</h3>
{place.distanceText && <small>{place.distanceText}</small>}
</div>
</button>
<PhotoCard
key={place.name}
image={place.imageUrl}
title={place.name}
meta={place.distanceText}
onClick={() => setOpen(place)}
/>
))}
</div>
<a href="#/area" className="more">

View File

@ -1,7 +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, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {Car} from '../paper/Car';
import {BOOK_EVENT} from '../paper/Rooms';
import {Weather} from './Weather';
@ -20,7 +20,6 @@ export function Hero() {
const region = place.addressLocality ?? place.addressRegion;
const booking = bookingLinks(payload)[0];
const stay = lodging ? stayBookingView(payload) : null;
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
return (
<>
@ -56,16 +55,7 @@ export function Hero() {
) : (
fixed && <p className="hl in">{fixed}</p>
)}
{lodging && fixed && <p className="tagline">{fixed}</p>}
{chips.length > 0 && (
<div className="chips">
{chips.map((chip) => (
<span key={chip} className="chip">
{chip}
</span>
))}
</div>
)}
<HeroRotator className="tagline" />
<div className="hero-cta">
{stay ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon,
} as const;
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
function observed(iso: string): string | undefined {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
}
export function Weather() {

View File

@ -337,3 +337,5 @@
.w4-round #about .amen li.on .ic{color:#15803d}
.w4-round #about .amen li.off .ic{color:#c2410c}
.w4-round #about .amen li.off{color:var(--sub)}
.w4-round{--kc-r:var(--r-card);--kc-tw:700;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:var(--alt)}
.w4-round .cards2,.w4-round .guide--3{row-gap:24px}

View File

@ -11,7 +11,8 @@ export function formatKoreanDate(iso: string | null | undefined): string {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`;
const [year, month, day] = date.toLocaleDateString('sv-SE', {timeZone: 'Asia/Seoul'}).split('-').map(Number);
return `${year}년 ${month}월 ${day}일`;
}
/** 왜 한글 표기가 아닌가 답변 엔진은 본문에서 YYYY-MM-DD 를 찾아 최신성을 매긴다. */

View File

@ -41,7 +41,7 @@ export function condition(code: number): string {
return WEATHER_CONDITION_BY_CODE[code] ?? '흐림';
}
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */
/** 날씨는 열 때마다 바뀌는 값이라 HTML 에 굽지 않는다 — 하이드레이션 뒤에 스냅샷을 띄우고 최신 값으로 갈아 끼운다. */
export function useLiveWeather({
initial,
regionCode,
@ -53,7 +53,11 @@ export function useLiveWeather({
latitude?: number;
longitude?: number;
}): LiveWeather | undefined {
const [weather, setWeather] = useState<LiveWeather | undefined>(initial);
const [weather, setWeather] = useState<LiveWeather | undefined>(undefined);
useEffect(() => {
setWeather((current) => current ?? initial);
}, [initial]);
useEffect(() => {
if (!regionCode || latitude == null || longitude == null) return;

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 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,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];

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

View File

@ -4,7 +4,7 @@ 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 {WeatherSection} from './WeatherSection';
import {WeatherSection, weatherLineSources} from './WeatherSection';
import type {WeatherSnapshot} from '@o2o/shared';
function render(weather?: WeatherSnapshot) {
@ -13,29 +13,33 @@ function render(weather?: WeatherSnapshot) {
return renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><WeatherSection /></LayoutProvider></SiteProvider>);
}
it('uses the first sky and temperature lines for deterministic HTML', () => {
it('keeps the weather out of the baked HTML — it is filled in the browser', () => {
const html = render({temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄']}});
expect(html).toBe('');
});
it('picks sky lines by condition and temperature lines by band', () => {
const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}};
expect(render(weather)).toContain('맑음 첫 줄');
expect(render(weather)).toContain('더위 첫 줄');
expect(render(weather)).not.toContain('다음 줄');
expect(render(weather)).toBe(render(weather));
const sources = weatherLineSources(weather, weather);
expect(sources.sky).toEqual(['맑음 첫 줄', '다음 줄']);
expect(sources.temp).toEqual(['더위 첫 줄']);
});
it('uses condition-specific notes for fog, showers and thunderstorms', () => {
const weather = {temperature: 20, observedAt: '2026-09-15T12:00', condition: '보통 소나기', noteSets: {'보통 소나기': ['소나기 첫 줄']}, tempNoteSets: {'선선': ['선선 첫 줄']}};
expect(render(weather)).toContain('소나기 첫 줄');
expect(weatherLineSources(weather, weather).sky).toEqual(['소나기 첫 줄']);
});
it('uses condition-specific notes for the rain subtypes', () => {
const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '강한 착빙성 비', noteSets: {'강한 착빙성 비': ['어는비 첫 줄'], '보통 비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}};
expect(render(weather)).toContain('어는비 첫 줄');
expect(render(weather)).not.toContain('일반 비 첫 줄');
expect(weatherLineSources(weather, weather).sky).toEqual(['어는비 첫 줄']);
});
it('preserves legacy single notes', () => {
const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}});
expect(html).toContain('기존 비 안내');
expect(html).toContain('겉옷 안내');
const weather = {temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}};
const sources = weatherLineSources(weather, weather);
expect(sources.skyFallback).toBe('기존 비 안내');
expect(sources.tempFallback).toBe('겉옷 안내');
});
it('does not invent weather when an observation is missing', () => {