merge: origin/main 을 main 에 병합 — 온톨로지 배포 전 동기화

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-10-01 15:05:25 +09:00
commit c7749662fd
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·사진 적재 | | 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
| 2 VISION | `vision_service.run_vision` | 사진 분류 + alt 생성 | | 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` | ★ 정적 빌드 + 발행 게이트 | | 4 BUILD | `build_service.run_build` | ★ 정적 빌드 + 발행 게이트 |
| 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) | | 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) |
| 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 | | 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 |

View File

@ -8,7 +8,7 @@
↓ ↓
새로고침 ──────────────────────→ GET /v1/job/{jobId} 새로고침 ──────────────────────→ GET /v1/job/{jobId}
↑ ↑
COPY 워커: prepare → generate → save → faq_fill COPY 워커: prepare → generate → save → catchphrase(숙박만) → faq_fill
각 단계 진입·완료 → jobs.progress(JSONB) 각 단계 진입·완료 → jobs.progress(JSONB)
↓ ↓
화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor 화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor

View File

@ -49,6 +49,14 @@
- 같은 목록의 카드는 같은 크기: 사진은 고정 비율 + `object-fit:cover`, 글은 줄 수 말줄임 - 같은 목록의 카드는 같은 크기: 사진은 고정 비율 + `object-fit:cover`, 글은 줄 수 말줄임
- 칸 수는 항목 수에 맞춘다(`repeat(auto-fit, minmax(…))`). 3개인데 4칸을 잡아 빈칸을 남기지 않는다 - 칸 수는 항목 수에 맞춘다(`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. 섹션 머리 ## 4. 섹션 머리
- **제목은 위, 내용은 아래.** 참고한 펜션 사이트들이 모두 이 방식이다 - **제목은 위, 내용은 아래.** 참고한 펜션 사이트들이 모두 이 방식이다
@ -77,6 +85,13 @@
## 6. 값 보여주기 ## 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>` - 마크업: `<ul class="amen"><li class="on"><i class="ic">✓</i><span><b>주차</b> 가능</span></li>`
- 색: ✓ `#15803d`, ✕ `#c2410c`. 가능한 것을 먼저 놓는다 - 색: ✓ `#15803d`, ✕ `#c2410c`. 가능한 것을 먼저 놓는다
@ -91,6 +106,7 @@
- 같은 동작은 한 이름: 예약 시트를 여는 버튼은 모두 **‘예약하기’**, 이용안내 탭으로 가는 버튼은 **‘이용안내 보기’** - 같은 동작은 한 이름: 예약 시트를 여는 버튼은 모두 **‘예약하기’**, 이용안내 탭으로 가는 버튼은 **‘이용안내 보기’**
- 버튼 글자가 두 줄로 접히면 안 된다. 모바일에서 버튼이 3개 이상이면 2열 격자로, 남는 하나는 한 줄 전체 - 버튼 글자가 두 줄로 접히면 안 된다. 모바일에서 버튼이 3개 이상이면 2열 격자로, 남는 하나는 한 줄 전체
- 키보드 포커스와 글자 선택색은 템플릿 색으로(`kit/kit.css`) - 키보드 포커스와 글자 선택색은 템플릿 색으로(`kit/kit.css`)
- 상세·예약 창(`paper/Sheet.tsx`)은 모든 폭에서 **화면 가운데** 뜬다. 폭 560px 이하, 모서리는 템플릿의 `--k-r`
## 8. 하지 않는 것 ## 8. 하지 않는 것

View File

@ -24,6 +24,8 @@
{ "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" }, { "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": "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": "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": "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": "명" }, { "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 흐름.""" """COPY 흐름."""
from common.enums import PlaceCategory
from common.logger import LOG 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.external import gemini_text
from services.llm import provider from services.llm import provider
from services.llm.errors import LlmError
from services.job_progress import JobProgress 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: async def run_copy(job: dict) -> dict:
@ -34,6 +38,18 @@ async def run_copy(job: dict) -> dict:
async with progress.step("save"): async with progress.step("save"):
result = await save_copy(inputs, copy) 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: if inputs.catalog is not None:
async with progress.step("faq_fill"): async with progress.step("faq_fill"):
result["faq_fill"] = await fill_faqs( 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.faq_crud import FaqCRUD
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact 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.external import gemini_text
from services.llm import provider from services.llm import provider
from services.fact_service import FactService from services.fact_service import FactService
@ -248,6 +248,47 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None)
return stat 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: async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int:
"""노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다.""" """노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다."""
l_err, rows = await DB_SESSION_MNG.execute_lambda( 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 LlmError
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured 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 from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
@ -159,6 +160,90 @@ async def generate_copy(
return result 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) ────────────────────────────────────────────────── # ── 요약(summarize_text) ──────────────────────────────────────────────────
_SUMMARY_CACHE: dict[str, str] = {} _SUMMARY_CACHE: dict[str, str] = {}
_SUMMARY_CACHE_MAX = 500 _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.logger import LOG
from common.template_catalog import TEMPLATES, industry_of, resolve_template_id 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.intro_summary import summarize_intro
from services.stay_guide import nol_stay_guide from services.stay_guide import nol_stay_guide
from services.weather_notes import weather_notes from services.weather_notes import weather_notes
@ -33,6 +34,9 @@ DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads"
SITE_HOST_ENV = "SITE_PUBLIC_HOST" SITE_HOST_ENV = "SITE_PUBLIC_HOST"
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost" DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
_HERO_COPY_KEYS = {"tagline", "catchphrases"}
def _scheme(host: str) -> str: def _scheme(host: str) -> str:
"""localhost 는 http 다.""" """localhost 는 http 다."""
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https" 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, "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) 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, "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, "units": units,
"media": media, "media": media,
"faqs": faqs, "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) 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): 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"] 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"] job = (await client.get(f"/v1/job/{first['job_id']}", headers=h)).json()["job"]
assert job["place_id"] == pid assert job["place_id"] == pid
assert [(s["id"], s["status"]) for s in job["progress"]["steps"]] == [ 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() fresh = (await client.post(path, headers=h, json={})).json()
assert fresh["job_id"] != first["job_id"] 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): async def generate(*args, **kwargs):
row = await JobQueue().get(started["job_id"]) row = await JobQueue().get(started["job_id"])
assert row["status"] == JobStatus.RUNNING.value 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() return _copy()
monkeypatch.setattr(gemini_text, "generate_copy", generate) 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) pid = await _place_with_facts(client, h)
r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
assert r.json()["result"]["code"] == ErrorType.GENERATOR_NOT_CONFIGURED.value 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(" ") result = await gt.summarize_text(" ")
assert result is None 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: '수집된 사진 분류 및 대체 텍스트 생성', prepare: '수집된 사진 분류 및 대체 텍스트 생성',
generate: '브랜드 컬러 시스템 및 타이포그래피 조합', generate: '브랜드 컬러 시스템 및 타이포그래피 조합',
save: '확인된 사실만으로 소개 콘텐츠 구성', save: '확인된 사실만으로 소개 콘텐츠 구성',
catchphrase: '첫 화면 캐치프레이즈와 순환 문구 작성',
faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비', faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비',
}; };
@ -10,5 +11,6 @@ export const SKIP_REASONS: Record<string, string> = {
no_facts: '확인된 정보 없음', no_facts: '확인된 정보 없음',
not_configured: '생성 서비스 미설정', not_configured: '생성 서비스 미설정',
no_catalog: '공통 질문 대상 업종 아님', no_catalog: '공통 질문 대상 업종 아님',
not_lodging: '숙박 업종만 해당',
generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다', generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다',
}; };

View File

@ -322,10 +322,19 @@ export interface RouteEntry {
} }
export interface Narrative { export interface Narrative {
/** 히어로 둘째 줄에서 순환할 문구. */ /** 히어로 둘째 줄에서 순환할 문구. 첫째 줄(캐치프레이즈)은 `tagline` 하나로 고정이다. */
catchphrases?: { catchphrases?: {
version: 1; version: 1;
items: {text: string; kind?: 'general' | 'season' | 'month' | 'weather'}[]; items: {
text: string;
kind?: 'general' | 'season' | 'month' | 'weather';
/** kind=season 일 때 봄·여름·가을·겨울. */
season?: string;
/** kind=month 일 때 1~12. */
month?: number;
/** kind=weather 일 때 맑음·흐림·비·눈. */
weather?: string;
}[];
}; };
heroHeadline?: string; heroHeadline?: string;

View File

@ -46,7 +46,7 @@ it('고택은 섹션 순서와 상관없이 날씨를 첫 화면 바로 아래
payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])]; payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])];
const html = renderToStaticMarkup(<App payload={payload} />); const html = renderToStaticMarkup(<App payload={payload} />);
const hero = html.indexOf('class="hero"'); 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(hero).toBeGreaterThan(-1);
expect(brief).toBeGreaterThan(hero); expect(brief).toBeGreaterThan(hero);
expect(html.slice(hero, brief)).not.toContain('class="sec'); 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 type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format'; import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet'; import {Sheet} from '../paper/Sheet';
import {Idx} from './SectionHead'; import {Idx} from './SectionHead';
import {Tabs} from './Tabs'; import {Tabs} from './Tabs';
@ -66,6 +67,17 @@ export function Around() {
</li> </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 ( return (
<> <>
{rail.length > 0 && ( {rail.length > 0 && (
@ -86,16 +98,14 @@ export function Around() {
<section id="walk" aria-labelledby="walk-heading"> <section id="walk" aria-labelledby="walk-heading">
<Idx id="walk-heading" title="주변 명소" aside={`${spots.length} Spots`} /> <Idx id="walk-heading" title="주변 명소" aside={`${spots.length} Spots`} />
<Tabs items={rangeTabs(spots)} value={spotRange} onChange={setSpotRange} /> <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 && ( {shownSpots.length > firstSpots && (
<details> <details>
<summary className="line"> <summary className="line">
<span>{shownSpots.length - firstSpots}곳 더 보기</span> <span>{shownSpots.length - firstSpots}곳 더 보기</span>
<span>+</span> <span>+</span>
</summary> </summary>
<ul className="guide"> <div className="kgrid">{shownSpots.slice(firstSpots).map(spot)}</div>
{shownSpots.slice(firstSpots).map((place, i) => item(place, i + firstSpots))}
</ul>
</details> </details>
)} )}
</section> </section>

View File

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

View File

@ -1,5 +1,5 @@
import {PlaceCategory} from '@o2o/shared'; import {PlaceCategory} from '@o2o/shared';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive'; import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {BOOK_EVENT} from '../paper/Rooms'; import {BOOK_EVENT} from '../paper/Rooms';
@ -29,7 +29,7 @@ export function Hero() {
<span className="no" /> 오늘의 문장 <span className="no" /> 오늘의 문장
</p> </p>
{(lodging || fixed) && <p className="rot">{lodging ? <HeroCatchphrase /> : fixed}</p>} {(lodging || fixed) && <p className="rot">{lodging ? <HeroCatchphrase /> : fixed}</p>}
{lodging && fixed && <p className="m sub">{fixed}</p>} <HeroRotator className="m sub" />
<div className="hx"> <div className="hx">
{stay ? ( {stay ? (
<button type="button" className="line line--ink" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}> <button type="button" className="line line--ink" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>

View File

@ -284,3 +284,7 @@
.w4-bigtype .ppl{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))} .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 .amen{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}}
.w4-bigtype .lead+:is(.chips,.tabrow){margin-top:20px} .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 type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format'; import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet'; import {Sheet} from '../paper/Sheet';
const WALK = 80; const WALK = 80;
@ -70,6 +71,17 @@ export function Around() {
</button> </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 ( return (
<> <>
{rail.length > 0 && ( {rail.length > 0 && (
@ -78,7 +90,7 @@ export function Around() {
<p className="label">AROUND</p> <p className="label">AROUND</p>
<h2 id="around-heading">주변 안내</h2> <h2 id="around-heading">주변 안내</h2>
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p> <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 className="link b-more--c" href="#/area">
지역 소개에서 모두 보기 지역 소개에서 모두 보기
</a> </a>
@ -95,11 +107,11 @@ export function Around() {
</h2> </h2>
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p> <p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} /> <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 && ( {shownSpots.length > firstSpots && (
<details className="fold" key={spotRange}> <details className="fold" key={spotRange}>
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary> <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> </details>
)} )}
</section> </section>

View File

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

View File

@ -1,5 +1,5 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive'; import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {PlaceCategory} from '@o2o/shared'; import {PlaceCategory} from '@o2o/shared';
@ -26,7 +26,6 @@ export function Hero() {
}, 5000); }, 5000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [total]); }, [total]);
const fixed = narrative.tagline ?? narrative.heroSubline;
const english = place.englishName?.trim(); const english = place.englishName?.trim();
const locality = [place.addressLocality, place.addressRegion].find(Boolean); const locality = [place.addressLocality, place.addressRegion].find(Boolean);
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null; const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
@ -57,10 +56,10 @@ export function Hero() {
{english && <p className="b-hero-en">{english}</p>} {english && <p className="b-hero-en">{english}</p>}
<h1 className={english ? 'b-hero-name' : 'b-hero-name b-hero-name--solo'}>{place.name}</h1> <h1 className={english ? 'b-hero-name' : 'b-hero-name b-hero-name--solo'}>{place.name}</h1>
{locality && <p className="b-hero-loc">{locality}</p>} {locality && <p className="b-hero-loc">{locality}</p>}
{fixed && <p className="b-hero-ko">{fixed}</p>}
<p className="b-hero-cp"> <p className="b-hero-cp">
<HeroCatchphrase /> <HeroCatchphrase />
</p> </p>
<HeroRotator className="b-hero-ko" />
<div className="b-hero-act"> <div className="b-hero-act">
{stayBooking ? ( {stayBooking ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}> <button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>

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 .b-rule{margin-left:0;margin-right:0}
.w4-boutique[data-page=story] main .shell{padding-inline:24px} .w4-boutique[data-page=story] main .shell{padding-inline:24px}
.w4-boutique[data-page=story] main #reviews{margin-top:var(--sec)}} .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 type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format'; import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet'; import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide'; import {useWide} from './useWide';
@ -71,6 +72,17 @@ export function Around() {
</button> </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 ( return (
<> <>
{rail.length > 0 && ( {rail.length > 0 && (
@ -81,11 +93,13 @@ export function Around() {
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p> <p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
<div className="grid3"> <div className="grid3">
{rail.slice(0, HOME_ROWS).map((item) => ( {rail.slice(0, HOME_ROWS).map((item) => (
<button key={item.name} type="button" className="card" onClick={() => setOpen(item)}> <PhotoCard
<img src={item.imageUrl} alt={item.name} loading="lazy" /> key={item.name}
<b className="nm clamp">{item.name}</b> image={item.imageUrl}
{item.distanceText && <span className="ds">{item.distanceText}</span>} title={item.name}
</button> meta={item.distanceText}
onClick={() => setOpen(item)}
/>
))} ))}
</div> </div>
<a className="link more" href="#/area"> <a className="link more" href="#/area">
@ -104,11 +118,11 @@ export function Around() {
</h2> </h2>
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p> <p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} /> <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 && ( {shownSpots.length > firstSpots && (
<details className="fold"> <details className="fold">
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary> <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> </details>
)} )}
</section> </section>

View File

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

View File

@ -1,5 +1,5 @@
import {PlaceCategory} from '@o2o/shared'; import {PlaceCategory} from '@o2o/shared';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {galleryImages, isSectionEnabled} from '@site/lib/derive'; import {galleryImages, isSectionEnabled} from '@site/lib/derive';
import {useLiveWeather} from '@site/lib/use-live-weather'; import {useLiveWeather} from '@site/lib/use-live-weather';
@ -56,7 +56,8 @@ export function Hero() {
<HeroCatchphrase /> <HeroCatchphrase />
</p> </p>
<h1 className="nm">{place.name}</h1> <h1 className="nm">{place.name}</h1>
{sub && <p className="sub">{sub}</p>} {where && <p className="sub">{where}</p>}
<HeroRotator className="sub" />
</> </>
) : ( ) : (
<> <>

View File

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

View File

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

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { export function Weather() {

View File

@ -447,3 +447,5 @@
.w4-coral #about .amen li.off .ic{color:#c2410c} .w4-coral #about .amen li.off .ic{color:#c2410c}
.w4-coral #about .amen li.off{color:var(--sub)} .w4-coral #about .amen li.off{color:var(--sub)}
.w4-coral :is(h1,h2,h3){font-family:var(--tpl-font-heading)} .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 type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format'; import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet'; import {Sheet} from '../paper/Sheet';
import {SpotIcon} from './Art'; import {SpotIcon} from './Art';
import {useWide} from './useWide'; import {useWide} from './useWide';
@ -70,6 +71,18 @@ export function Around() {
</button> </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 ( return (
<> <>
<div data-pg="home"> <div data-pg="home">
@ -103,11 +116,11 @@ export function Around() {
const shown = spots.filter((p) => inRange(spotRange)(meters(p))); const shown = spots.filter((p) => inRange(spotRange)(meters(p)));
return ( 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 && ( {shown.length > firstSpots && (
<details> <details>
<summary className="more">{shown.length - firstSpots}곳 더 보기</summary> <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> </details>
)} )}
</> </>

View File

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

View File

@ -1,15 +1,14 @@
import {useEffect, useState, type CSSProperties} from 'react'; import {useEffect, useState, type CSSProperties} from 'react';
import {PlaceCategory, type WeatherSky} from '@o2o/shared'; import {PlaceCategory, type WeatherSky} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {HeroRotator, useMainCatchphrase} from '@site/sections/HeroCatchphrase';
import {useLiveWeather} from '@site/lib/use-live-weather'; import {useLiveWeather} from '@site/lib/use-live-weather';
import {useWeatherNotes} from '@site/lib/use-weather-note'; import {useWeatherNotes} from '@site/lib/use-weather-note';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView, weatherBand, weatherMood} from '@site/lib/derive'; import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView, weatherBand, weatherMood} from '@site/lib/derive';
import {BOOK_EVENT} from '../paper/Rooms'; import {BOOK_EVENT} from '../paper/Rooms';
import lines from '@site/lib/lodging-catchphrases.json';
import {Hills, Sky} from './Art'; import {Hills, Sky} from './Art';
const STEP_MS = 35; const STEP_MS = 35;
const ROTATE_MS = 4500;
export function Pop({text}: {text: string}) { export function Pop({text}: {text: string}) {
const words = text.split(/\s+/).map((word) => [...word]); const words = text.split(/\s+/).map((word) => [...word]);
@ -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() { function WeatherCard() {
const payload = useSite(); const payload = useSite();
const weather = useLiveWeather({ const weather = useLiveWeather({
@ -101,9 +80,8 @@ function WeatherCard() {
export function Hero() { export function Hero() {
const payload = useSite(); const payload = useSite();
const {place, narrative} = payload; const {place, narrative} = payload;
const phrase = useCatchphrase(); const phrase = useMainCatchphrase();
const region = [place.addressRegion, place.addressLocality].filter(Boolean).join(' '); const region = [place.addressRegion, place.addressLocality].filter(Boolean).join(' ');
const fixed = narrative.tagline ?? narrative.heroSubline;
const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null; const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const link = bookingLinks(payload)[0]; const link = bookingLinks(payload)[0];
@ -111,7 +89,6 @@ export function Hero() {
<section className="scene" id="top" data-pg="home" aria-labelledby="hero-heading"> <section className="scene" id="top" data-pg="home" aria-labelledby="hero-heading">
<Sky /> <Sky />
<div className="hero-copy"> <div className="hero-copy">
{region && <span className="tag">{region}</span>}
<h1 id="hero-heading" className="disp"> <h1 id="hero-heading" className="disp">
<Pop text={place.name} /> <Pop text={place.name} />
</h1> </h1>
@ -120,7 +97,7 @@ export function Hero() {
<Pop text={phrase} /> <Pop text={phrase} />
</p> </p>
)} )}
{fixed && <p className="sub">{fixed}</p>} <HeroRotator className="sub" />
{(stay || link || place.phone) && ( {(stay || link || place.phone) && (
<div className="hgo"> <div className="hgo">
{stay ? ( {stay ? (

View File

@ -334,3 +334,7 @@
@media(max-width:1023px){.w4-graphic #location .btns{display:grid;grid-template-columns:1fr 1fr;gap:8px} @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>:first-child{grid-column:1/-1}
.w4-graphic #location .btns>*{white-space:nowrap;min-width:0}} .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){--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) *{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) .sbox{border-radius:var(--k-r)}
: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) :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) .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) :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) .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) .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} :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) .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{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) 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) .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) .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) .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{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} :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 type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format'; import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet'; import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide'; import {useWide} from './useWide';
@ -66,18 +67,14 @@ export function Around() {
); );
const card = (place: LocalPlace) => ( const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}> <PhotoCard
{place.imageUrl && ( key={place.name}
<div className="ph"> image={place.imageUrl}
<img src={place.imageUrl} alt={place.name} loading="lazy" /> title={place.name}
</div> meta={place.distanceText}
)} desc={place.description}
<div className="bd"> onClick={() => setOpen(place)}
<h3>{place.name}</h3> />
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
); );
return ( return (
@ -94,15 +91,13 @@ export function Around() {
</div> </div>
<div className="guide guide--3"> <div className="guide guide--3">
{rail.slice(0, railMax).map((place) => ( {rail.slice(0, railMax).map((place) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}> <PhotoCard
<div className="ph"> key={place.name}
<img src={place.imageUrl} alt={place.name} loading="lazy" /> image={place.imageUrl}
</div> title={place.name}
<div className="bd"> meta={place.distanceText}
<h3>{place.name}</h3> onClick={() => setOpen(place)}
{place.distanceText && <small>{place.distanceText}</small>} />
</div>
</button>
))} ))}
</div> </div>
<a href="#/area" className="more"> <a href="#/area" className="more">

View File

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

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { export function Weather() {

View File

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

View File

@ -2,6 +2,7 @@ import {useState} from 'react';
import type {FestivalEntry} from '@o2o/shared'; import type {FestivalEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverSearchUrl} from '@site/lib/format'; import {naverSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Car} from './Car'; import {Car} from './Car';
import {Sheet} from './Sheet'; import {Sheet} from './Sheet';
@ -48,19 +49,12 @@ export function Festival() {
arrows arrows
label="계절별 축제" label="계절별 축제"
slides={shown.map((f) => ( slides={shown.map((f) => (
<button type="button" className="itemcard" onClick={() => setOpen(f)}> <PhotoCard
{f.imageUrl ? ( image={f.imageUrl}
<img src={f.imageUrl} alt={f.name} loading="lazy" style={{aspectRatio: '3 / 2'}} /> title={f.name}
) : ( meta={[f.month, f.location].filter(Boolean).join(' · ')}
<span className="noimg" style={{aspectRatio: '3 / 2'}}> onClick={() => setOpen(f)}
{f.name} />
</span>
)}
<p className="cap">
<b>{f.name}</b>
</p>
<p className="cap dim">{[f.month, f.location].filter(Boolean).join(' · ')}</p>
</button>
))} ))}
/> />
</section> </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 {useSite} from '@site/lib/site-context';
import {isSectionEnabled} from '@site/lib/derive'; import {isSectionEnabled} from '@site/lib/derive';
import {Weather} from './Weather'; import {Weather} from './Weather';
@ -13,7 +13,6 @@ export function Hero() {
.filter((image) => image.alt?.trim()) .filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)) .sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, MAX_SLIDES); .slice(0, MAX_SLIDES);
const fixed = narrative.tagline ?? narrative.heroSubline;
return ( return (
<> <>
@ -41,10 +40,10 @@ export function Hero() {
<p className="eyebrow">{place.addressLocality ?? place.addressRegion}</p> <p className="eyebrow">{place.addressLocality ?? place.addressRegion}</p>
)} )}
<h1 className="mj">{place.name}</h1> <h1 className="mj">{place.name}</h1>
{fixed && <p className="tagline">{fixed}</p>}
<p className="cp"> <p className="cp">
<HeroCatchphrase /> <HeroCatchphrase />
</p> </p>
<HeroRotator className="tagline" />
</div> </div>
</section> </section>
{isSectionEnabled(payload, 'weather') && ( {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 type {ChronicleItem, PeopleItem, ReadingItem, SongItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive'; import {sectionItems, sectionName} from '@site/lib/derive';
import {PhotoCard} from '../kit/Card';
import {Sheet} from './Sheet'; import {Sheet} from './Sheet';
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'}; 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>} {item.year && <span className="r">{item.year}</span>}
</button> </button>
); );
const person = (item: PeopleItem) => ( const person = (item: PeopleItem) => {
<button const show = () =>
key={item.name} setOpen({
type="button" title: item.name,
className="itemcard" meta: [item.role, item.years].filter(Boolean).join(' · '),
onClick={() => body: item.oneLine,
setOpen({ image: item.imageUrl,
title: item.name, });
meta: [item.role, item.years].filter(Boolean).join(' · '), if (item.imageUrl)
body: item.oneLine, return (
image: item.imageUrl, <PhotoCard
}) key={item.name}
} image={item.imageUrl}
> title={item.name}
{item.imageUrl && <img src={item.imageUrl} alt={item.name} loading="lazy" style={{aspectRatio: '1 / 1'}} />} meta={item.role}
<p className="cap"> desc={item.oneLine}
<b>{item.name}</b> {item.role && <span className="dim">{item.role}</span>} ratio="1 / 1"
</p> onClick={show}
{item.oneLine && <p className="cap dim clamp">{item.oneLine}</p>} />
</button> );
); 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) => ( const event = (item: ChronicleItem) => (
<button <button
key={`${item.year}-${item.title}`} key={`${item.year}-${item.title}`}

View File

@ -255,10 +255,10 @@
@supports(padding:max(0px)){ @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 .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 .sheet[hidden]{display:none}
.w4p .sbg{position:absolute;inset:0;background:rgba(14,13,10,.52)} .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 .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{padding-top:16px}
.w4p .sin img{margin:0 0 18px} .w4p .sin img{margin:0 0 18px}
@ -338,3 +338,4 @@
.w4p #w4d-panel .w4d-eq i{background:#8c4a2f} .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-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 #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 type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format'; import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet'; import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide'; import {useWide} from './useWide';
@ -66,18 +67,14 @@ export function Around() {
); );
const card = (place: LocalPlace) => ( const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}> <PhotoCard
{place.imageUrl && ( key={place.name}
<div className="ph"> image={place.imageUrl}
<img src={place.imageUrl} alt={place.name} loading="lazy" /> title={place.name}
</div> meta={place.distanceText}
)} desc={place.description}
<div className="bd"> onClick={() => setOpen(place)}
<h3>{place.name}</h3> />
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
); );
return ( return (

View File

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

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { 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 .map{aspect-ratio:auto;height:100%;min-height:320px}
.w4-pine #location .loc .bd{align-self:center;padding:28px} .w4-pine #location .loc .bd{align-self:center;padding:28px}
.w4-pine #festival .car--multi .slide{flex:0 0 260px}} .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 type {LocalPlace} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {naverMapSearchUrl} from '@site/lib/format'; import {naverMapSearchUrl} from '@site/lib/format';
import {PhotoCard} from '../kit/Card';
import {Sheet} from '../paper/Sheet'; import {Sheet} from '../paper/Sheet';
import {useWide} from './useWide'; import {useWide} from './useWide';
@ -66,18 +67,14 @@ export function Around() {
); );
const card = (place: LocalPlace) => ( const card = (place: LocalPlace) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}> <PhotoCard
{place.imageUrl && ( key={place.name}
<div className="ph"> image={place.imageUrl}
<img src={place.imageUrl} alt={place.name} loading="lazy" /> title={place.name}
</div> meta={place.distanceText}
)} desc={place.description}
<div className="bd"> onClick={() => setOpen(place)}
<h3>{place.name}</h3> />
{place.distanceText && <small>{place.distanceText}</small>}
{place.description && <p className="clamp">{place.description}</p>}
</div>
</button>
); );
return ( return (
@ -94,15 +91,13 @@ export function Around() {
</div> </div>
<div className="guide guide--3"> <div className="guide guide--3">
{rail.slice(0, railMax).map((place) => ( {rail.slice(0, railMax).map((place) => (
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}> <PhotoCard
<div className="ph"> key={place.name}
<img src={place.imageUrl} alt={place.name} loading="lazy" /> image={place.imageUrl}
</div> title={place.name}
<div className="bd"> meta={place.distanceText}
<h3>{place.name}</h3> onClick={() => setOpen(place)}
{place.distanceText && <small>{place.distanceText}</small>} />
</div>
</button>
))} ))}
</div> </div>
<a href="#/area" className="more"> <a href="#/area" className="more">

View File

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

View File

@ -12,20 +12,9 @@ const MOOD_ICON = {
눈: SnowIcon, 눈: SnowIcon,
} as const; } 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 { function observed(iso: string): string | undefined {
const date = new Date(iso); const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
if (Number.isNaN(date.getTime())) return undefined; return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
} }
export function Weather() { export function Weather() {

View File

@ -337,3 +337,5 @@
.w4-round #about .amen li.on .ic{color:#15803d} .w4-round #about .amen li.on .ic{color:#15803d}
.w4-round #about .amen li.off .ic{color:#c2410c} .w4-round #about .amen li.off .ic{color:#c2410c}
.w4-round #about .amen li.off{color:var(--sub)} .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 ''; if (!iso) return '';
const date = new Date(iso); const date = new Date(iso);
if (Number.isNaN(date.getTime())) return ''; 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 를 찾아 최신성을 매긴다. */ /** 왜 한글 표기가 아닌가 답변 엔진은 본문에서 YYYY-MM-DD 를 찾아 최신성을 매긴다. */

View File

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

View File

@ -1,37 +1,127 @@
import {useEffect, useState, type CSSProperties} from 'react'; import {useEffect, useLayoutEffect, useRef, useState, type CSSProperties} from 'react';
import {PlaceCategory} from '@o2o/shared'; import {PlaceCategory, type Narrative} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import lines from '@site/lib/lodging-catchphrases.json'; import lines from '@site/lib/lodging-catchphrases.json';
import './hero-catchphrase.css'; import './hero-catchphrase.css';
export function HeroCatchphrase({children}: {children?: string}) { type Item = NonNullable<Narrative['catchphrases']>['items'][number];
const {place, narrative} = useSite();
const lodging = place.category === PlaceCategory.LODGING;
const [index, setIndex] = useState(0);
const own = (narrative.catchphrases?.items ?? [])
.filter((item) => (item.kind ?? 'general') === 'general')
.map((item) => item.text.trim())
.filter(Boolean);
const phrases = own.length > 0 ? own : lines;
useEffect(() => {
if (!lodging) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const timer = window.setInterval(() => {
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
}, 6000);
return () => window.clearInterval(timer);
}, [lodging, phrases.length]);
if (!lodging) return children; const ROTATE_MS = 6000;
const text = phrases[index % phrases.length]; const SEASON_OF_MONTH = ['겨울', '겨울', '봄', '봄', '봄', '여름', '여름', '여름', '가을', '가을', '가을', '겨울'];
function stableIndex(key: string, size: number): number {
let hash = 0;
for (const ch of key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
return hash % size;
}
export function weatherBucket(condition?: string): string | undefined {
if (!condition) return undefined;
if (/눈/.test(condition)) return '눈';
if (/비|소나기/.test(condition)) return '비';
if (/흐림|구름/.test(condition)) return '흐림';
if (/맑/.test(condition)) return '맑음';
return undefined;
}
export function buildCycle(items: Item[], month: number, weather?: string, shuffle = true): string[] {
const text = (list: Item[]) => list.map((item) => item.text.trim()).filter(Boolean);
const general = text(items.filter((item) => (item.kind ?? 'general') === 'general'));
if (shuffle) {
for (let i = general.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[general[i], general[j]] = [general[j], general[i]];
}
}
const pick = (list: string[]) => (list.length ? list[Math.floor(Math.random() * list.length)] : undefined);
const season = text(items.filter((item) => item.kind === 'season' && item.season === SEASON_OF_MONTH[month - 1]));
const monthly = text(items.filter((item) => item.kind === 'month' && Number(item.month) === month));
const weathered = weather ? text(items.filter((item) => item.kind === 'weather' && item.weather === weather)) : [];
const out: string[] = [];
let g = 0;
const nextGeneral = () => general[g++ % Math.max(general.length, 1)];
for (let round = 0; round < Math.max(1, Math.ceil(general.length / 3)); round += 1) {
out.push(nextGeneral(), shuffle ? pick(season) ?? '' : season[0] ?? '', nextGeneral(), shuffle ? pick(monthly) ?? '' : monthly[0] ?? '', nextGeneral());
if (weathered.length) out.push(shuffle ? pick(weathered) ?? '' : weathered[0]);
}
return out.filter(Boolean);
}
export function useMainCatchphrase(fallback?: string): string | undefined {
const {place, narrative, site} = useSite();
if (place.category !== PlaceCategory.LODGING) return undefined;
const own = (narrative.tagline ?? narrative.heroSubline ?? fallback)?.trim();
return own || lines[stableIndex(site.siteId || place.name, lines.length)];
}
export function HeroCatchphrase({children}: {children?: string}) {
const text = useMainCatchphrase(children);
if (!text) return children;
return ( return (
<span className="hero-catchphrase"> <span className="hero-catchphrase">
{children && <span className="hero-catchphrase-fixed">{children}</span>} {text.split(/\s+/).map((word, i) => (
<span className="w4d-line w4d-line--in" key={index}> <span key={i}>
{text.split(/\s+/).map((word, i) => ( {i > 0 && ' '}
<span key={i}>{i > 0 && ' '}<span className="w4d-w"><span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span></span></span> <span className="w4d-w">
))} <span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span>
</span> </span>
</span>
))}
</span> </span>
); );
} }
export function HeroRotator({className}: {className?: string}) {
const {place, narrative, local} = useSite();
const box = narrative.catchphrases;
const items = box?.items ?? [];
const month = new Date().getMonth() + 1;
const [cycle, setCycle] = useState(() => buildCycle(items, month, weatherBucket(local.weather?.condition), false));
const [index, setIndex] = useState(0);
const [minHeight, setMinHeight] = useState<number>();
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
setCycle(buildCycle(box?.items ?? [], new Date().getMonth() + 1, weatherBucket(local.weather?.condition)));
}, [box, local.weather?.condition]);
useEffect(() => {
if (cycle.length < 2) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const timer = window.setInterval(() => {
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % cycle.length);
}, ROTATE_MS);
return () => window.clearInterval(timer);
}, [cycle.length]);
useLayoutEffect(() => {
const node = ref.current;
if (!node) return;
const measure = () => {
const width = node.getBoundingClientRect().width;
if (!width) return;
const probe = node.cloneNode(false) as HTMLElement;
probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;min-height:0;width:${width}px`;
node.parentNode?.appendChild(probe);
let max = 0;
for (const line of cycle) {
probe.textContent = line;
max = Math.max(max, probe.getBoundingClientRect().height);
}
probe.remove();
setMinHeight(Math.ceil(max));
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [cycle]);
if (place.category !== PlaceCategory.LODGING || cycle.length === 0) return null;
return (
<p ref={ref} className={['hero-rotator', className].filter(Boolean).join(' ')} style={{minHeight}} aria-live="off">
<span className="hero-rotator-line" key={`${index}-${cycle[index % cycle.length]}`}>
{cycle[index % cycle.length]}
</span>
</p>
);
}

View File

@ -1,8 +1,8 @@
/** 오늘의 날씨. */ /** 오늘의 날씨. */
import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react'; 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 {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 {useWeatherNotes} from '@site/lib/use-weather-note';
import {weatherBand, weatherMood} from '@site/lib/derive'; import {weatherBand, weatherMood} from '@site/lib/derive';
import {Card, Section} from '@site/lib/ui'; 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} 관측`; 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() { export function WeatherSection() {
const payload = useSite(); const payload = useSite();
const weather = useLiveWeather({ const weather = useLiveWeather({
@ -27,15 +41,9 @@ export function WeatherSection() {
const mood = weatherMood(weather?.condition ?? ''); const mood = weatherMood(weather?.condition ?? '');
const band = weatherBand(weather?.temperature ?? 0); const band = weatherBand(weather?.temperature ?? 0);
const notes = payload.local.weather; const sources = weatherLineSources(weather, payload.local.weather);
// 하늘 문구는 아홉 갈래로 고른다(WeatherSky).
const condition = (weather?.condition ?? '') as WeatherSky;
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
/* 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */ /* 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */
const [line, bandLine] = useWeatherNotes( const [line, bandLine] = useWeatherNotes(sources.sky, sources.skyFallback, sources.temp, sources.tempFallback);
notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note,
notes?.tempNoteSets?.[band], notes?.tempNotes?.[band],
);
if (!weather) return null; if (!weather) return null;
const observed = observedText(weather.observedAt); const observed = observedText(weather.observedAt);
const SkyIcon = MOOD_ICON[mood]; const SkyIcon = MOOD_ICON[mood];

View File

@ -1,12 +1,16 @@
.hero-catchphrase { display: block; } .hero-catchphrase { display: block; word-break: keep-all; }
.hero-catchphrase-fixed { display: block; }
.hero-catchphrase .w4d-line { display: block; margin-top: 0.5rem; min-height: 1.5em; word-break: keep-all; }
.hero-catchphrase .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; } .hero-catchphrase .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; }
.hero-catchphrase .w4d-w > span { display: inline-block; animation: lodging-word-in 0.45s both; animation-delay: var(--w4d-d); } .hero-catchphrase .w4d-w > span { display: inline-block; animation: lodging-word-in 0.45s both; animation-delay: var(--w4d-d); }
.hero-rotator { word-break: keep-all; }
.hero-rotator-line { display: block; animation: hero-rotator-in 0.6s both; }
@keyframes lodging-word-in { @keyframes lodging-word-in {
from { opacity: 0; transform: translateY(100%); } from { opacity: 0; transform: translateY(100%); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@media (prefers-reduced-motion: reduce) { @keyframes hero-rotator-in {
.hero-catchphrase .w4d-w > span { animation: none; } from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.hero-catchphrase .w4d-w > span, .hero-rotator-line { animation: none; }
} }

View File

@ -1,26 +1,65 @@
import {expect, it} from 'vitest'; import {expect, it} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server'; import {renderToStaticMarkup} from 'react-dom/server';
import {PlaceCategory} from '@o2o/shared'; import {PlaceCategory, type SitePayload} from '@o2o/shared';
import {SiteProvider} from '@site/lib/site-context'; import {SiteProvider} from '@site/lib/site-context';
import {LayoutProvider} from '@site/lib/layout'; import {LayoutProvider} from '@site/lib/layout';
import {LAYOUTS} from '@site/layouts'; import {LAYOUTS} from '@site/layouts';
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
import lines from '@site/lib/lodging-catchphrases.json'; import lines from '@site/lib/lodging-catchphrases.json';
import {HeroCatchphrase} from './HeroCatchphrase'; import {buildCycle, HeroCatchphrase, HeroRotator, weatherBucket} from './HeroCatchphrase';
it('keeps the original lodging tagline in the static HTML', () => { const text = (html: string) => html.replace(/<[^>]+>/g, '');
const html = renderToStaticMarkup(<SiteProvider payload={MOONLIGHT_STAY_PAYLOAD}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>고유문구</HeroCatchphrase></LayoutProvider></SiteProvider>); const render = (payload: SitePayload, node: React.ReactNode) =>
expect(html).toContain('<span class="hero-catchphrase-fixed">고유문구</span>'); renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}>{node}</LayoutProvider></SiteProvider>);
expect(html).toContain('w4d-line--in');
const text = html.replace(/<[^>]+>/g, ''); it('shows the one fixed catchphrase and nothing that rotates in its place', () => {
expect(text).toContain('고유문구' + lines[0]); const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.narrative.tagline = '담 너머 백 년 된 집';
expect(text(render(payload, <HeroCatchphrase />))).toBe('담 너머 백 년 된 집');
}); });
it('does not add lodging copy or controls to another category', () => { it('falls back to one common line when the place has no catchphrase', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
delete payload.narrative.tagline;
delete payload.narrative.heroSubline;
expect(lines).toContain(text(render(payload, <HeroCatchphrase />)));
});
it('does not add lodging copy to another category', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.place.category = PlaceCategory.CAFE; payload.place.category = PlaceCategory.CAFE;
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>카페 소개</HeroCatchphrase></LayoutProvider></SiteProvider>); expect(render(payload, <HeroCatchphrase>카페 소개</HeroCatchphrase>)).toBe('카페 소개');
expect(html).toBe('카페 소개'); });
it('leaves the rotating line out when there is nothing to rotate', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
delete payload.narrative.catchphrases;
expect(render(payload, <HeroRotator />)).toBe('');
});
it('rotates general, season, general, month, general, weather like the stay mockup', () => {
const cycle = buildCycle(
[
{text: '일반1', kind: 'general'},
{text: '일반2', kind: 'general'},
{text: '일반3', kind: 'general'},
{text: '가을 문구', kind: 'season', season: '가을'},
{text: '봄 문구', kind: 'season', season: '봄'},
{text: '9월 문구', kind: 'month', month: 9},
{text: '비 문구', kind: 'weather', weather: '비'},
],
9,
'비',
false,
);
expect(cycle).toEqual(['일반1', '가을 문구', '일반2', '9월 문구', '일반3', '비 문구']);
});
it('reads the sky into the four weather buckets', () => {
expect(weatherBucket('구름많음')).toBe('흐림');
expect(weatherBucket('비/눈')).toBe('눈');
expect(weatherBucket('소나기')).toBe('비');
expect(weatherBucket('맑음')).toBe('맑음');
}); });
it('ships ten distinct short phrases', () => { it('ships ten distinct short phrases', () => {

View File

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