merge: origin/main 을 main 에 병합 — 온톨로지 배포 전 동기화
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c7749662fd
@ -276,7 +276,7 @@ COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다.
|
||||
|---|---|---|
|
||||
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
|
||||
| 2 VISION | `vision_service.run_vision` | 사진 분류 + alt 생성 |
|
||||
| 3 COPY | `copy_service.run_copy` | 소개문·FAQ (확보된 fact 만 근거) |
|
||||
| 3 COPY | `copy_service.run_copy` | 소개문·FAQ · 숙박은 첫 화면 캐치프레이즈(fact `tagline`)와 순환 문구(fact `catchphrases`, 한 줄에 하나, `봄 \| ` · `9월 \| ` · `비 \| ` 머리) (확보된 fact 만 근거) |
|
||||
| 4 BUILD | `build_service.run_build` | ★ 정적 빌드 + 발행 게이트 |
|
||||
| 5 LOCAL_SYNC | `story_service.run_local_sync` | 지역 이야기 생성(지역당 1회) |
|
||||
| 6 AI_CHECK | 미구현 | reports 모듈이 붙을 때 |
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
↓
|
||||
새로고침 ──────────────────────→ GET /v1/job/{jobId}
|
||||
↑
|
||||
COPY 워커: prepare → generate → save → faq_fill
|
||||
COPY 워커: prepare → generate → save → catchphrase(숙박만) → faq_fill
|
||||
각 단계 진입·완료 → jobs.progress(JSONB)
|
||||
↓
|
||||
화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor
|
||||
|
||||
@ -49,6 +49,14 @@
|
||||
- 같은 목록의 카드는 같은 크기: 사진은 고정 비율 + `object-fit:cover`, 글은 줄 수 말줄임
|
||||
- 칸 수는 항목 수에 맞춘다(`repeat(auto-fit, minmax(…))`). 3개인데 4칸을 잡아 빈칸을 남기지 않는다
|
||||
|
||||
### 사진 카드는 하나다 — `layouts/kit/Card.tsx` `PhotoCard`
|
||||
|
||||
- 주변 안내 · 축제 · 명소 · 인물(사진 있을 때)은 모두 `PhotoCard` 를 쓴다. 템플릿마다 카드를 새로 짓지 않는다
|
||||
- 모양: 사진(3:2) 아래에 제목 1줄 · 거리나 날짜 1줄 · 설명 2줄. 템플릿별로 다른 것은 `--kc-*` 변수만
|
||||
(`--kc-r` 사진 모서리, `--kc-tf`·`--kc-tw` 제목 글꼴·굵기, `--kc-mc` 거리 색, 상자형은 `--kc-bg`·`--kc-box-r`)
|
||||
- 사진 없는 목록(맛집 · 홈 가까운 곳 목록)은 행(row)으로 쓴다. 한 섹션 안에서 카드와 행을 섞지 않는다
|
||||
- 모바일 캐러셀 화살표는 숨긴다(카드 위를 가린다). 1024px 이상에서만 사진 가운데 높이에 둔다
|
||||
|
||||
## 4. 섹션 머리
|
||||
|
||||
- **제목은 위, 내용은 아래.** 참고한 펜션 사이트들이 모두 이 방식이다
|
||||
@ -77,6 +85,13 @@
|
||||
|
||||
## 6. 값 보여주기
|
||||
|
||||
- **첫 화면은 캐치프레이즈 한 줄(고정) → 그 아래 순환 문구.** `/s/stay` 목업과 같다(`sections/HeroCatchphrase.tsx`)
|
||||
- 캐치프레이즈 = `narrative.tagline`. 없으면 공통 문구 10개 중 사이트마다 정해진 하나
|
||||
- 순환 = `narrative.catchphrases`, 일반 → 계절 → 일반 → 월 → 일반 → 날씨 순서, 6초. 없으면 줄을 두지 않는다
|
||||
- 두 값은 COPY 잡이 소개문과 함께 만든다(fact `tagline` · `catchphrases`, `services/copy_steps.py`)
|
||||
- 첫 화면에 태그·칩(‘주차 가능’, 지역 말풍선)을 두지 않는다
|
||||
- 부티크 첫 화면은 모바일도 문(아치) 모양이다. 전체화면 사진으로 되돌리지 않는다
|
||||
|
||||
- **‘가능 · 있음’은 ✓, ‘불가 · 없음’은 ✕ 목록으로 쓴다.** `주차 / 가능` 같은 표로 쓰지 않는다
|
||||
- 마크업: `<ul class="amen"><li class="on"><i class="ic">✓</i><span><b>주차</b> 가능</span></li>`
|
||||
- 색: ✓ `#15803d`, ✕ `#c2410c`. 가능한 것을 먼저 놓는다
|
||||
@ -91,6 +106,7 @@
|
||||
- 같은 동작은 한 이름: 예약 시트를 여는 버튼은 모두 **‘예약하기’**, 이용안내 탭으로 가는 버튼은 **‘이용안내 보기’**
|
||||
- 버튼 글자가 두 줄로 접히면 안 된다. 모바일에서 버튼이 3개 이상이면 2열 격자로, 남는 하나는 한 줄 전체
|
||||
- 키보드 포커스와 글자 선택색은 템플릿 색으로(`kit/kit.css`)
|
||||
- 상세·예약 창(`paper/Sheet.tsx`)은 모든 폭에서 **화면 가운데** 뜬다. 폭 560px 이하, 모서리는 템플릿의 `--k-r`
|
||||
|
||||
## 8. 하지 않는 것
|
||||
|
||||
|
||||
@ -24,6 +24,8 @@
|
||||
{ "key": "accommodation_capacity", "label": "수용 인원", "type": "number", "scope": "place", "required": false, "critical": false, "allow_llm": false, "unit": "명" },
|
||||
{ "key": "building_scale", "label": "규모", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
|
||||
{ "key": "intro", "label": "숙소 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
|
||||
{ "key": "tagline", "label": "캐치프레이즈", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
|
||||
{ "key": "catchphrases", "label": "첫 화면 순환 문구", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
|
||||
|
||||
{ "key": "room_type", "label": "객실 타입", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
|
||||
{ "key": "standard_capacity", "label": "기준 인원", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "명" },
|
||||
|
||||
43
solution/backend/services/catchphrase_text.py
Normal file
43
solution/backend/services/catchphrase_text.py
Normal 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
|
||||
@ -1,11 +1,15 @@
|
||||
"""COPY 흐름."""
|
||||
from common.enums import PlaceCategory
|
||||
from common.logger import LOG
|
||||
from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs
|
||||
from services.copy_steps import (
|
||||
CopyAborted, fill_faqs, generate_catchphrases, generate_copy, prepare_copy, save_catchphrases, save_copy,
|
||||
)
|
||||
from services.external import gemini_text
|
||||
from services.llm import provider
|
||||
from services.llm.errors import LlmError
|
||||
from services.job_progress import JobProgress
|
||||
|
||||
COPY_STEPS = ("prepare", "generate", "save", "faq_fill")
|
||||
COPY_STEPS = ("prepare", "generate", "save", "catchphrase", "faq_fill")
|
||||
|
||||
|
||||
async def run_copy(job: dict) -> dict:
|
||||
@ -34,6 +38,18 @@ async def run_copy(job: dict) -> dict:
|
||||
async with progress.step("save"):
|
||||
result = await save_copy(inputs, copy)
|
||||
|
||||
if inputs.place.category != PlaceCategory.LODGING.value:
|
||||
await progress.skip("catchphrase", "not_lodging")
|
||||
elif copy is None:
|
||||
await progress.skip("catchphrase", "no_facts" if inputs.ungrounded else "not_configured")
|
||||
else:
|
||||
try:
|
||||
async with progress.step("catchphrase"):
|
||||
result["catchphrase"] = await save_catchphrases(inputs, await generate_catchphrases(inputs))
|
||||
except LlmError as ex:
|
||||
LOG.w(f"[copy] 첫 화면 문구 생성 실패, 공통 문구로 계속: {ex}")
|
||||
await progress.skip("catchphrase", "generation_failed")
|
||||
|
||||
if inputs.catalog is not None:
|
||||
async with progress.step("faq_fill"):
|
||||
result["faq_fill"] = await fill_faqs(
|
||||
|
||||
@ -21,7 +21,7 @@ from crud.fact_crud import FactCRUD
|
||||
from crud.faq_crud import FaqCRUD
|
||||
from crud.place_crud import PlaceCRUD
|
||||
from router.v1.fact.protocol import Req_UpsertFact
|
||||
from services import faq_fill, place_research
|
||||
from services import catchphrase_text, faq_fill, place_research
|
||||
from services.external import gemini_text
|
||||
from services.llm import provider
|
||||
from services.fact_service import FactService
|
||||
@ -248,6 +248,47 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None)
|
||||
return stat
|
||||
|
||||
|
||||
async def generate_catchphrases(inputs: CopyInputs) -> gemini_text.GeneratedCatchphrases:
|
||||
active_provider = provider.active()
|
||||
model = (
|
||||
external_api_config.openai_text_model if active_provider.__name__.endswith("openai")
|
||||
else external_api_config.gemini_text_model
|
||||
)
|
||||
try:
|
||||
return await gemini_text.generate_catchphrases(
|
||||
inputs.place.name, inputs.grounded, records=inputs.records or None, model=model,
|
||||
)
|
||||
except gemini_text.GeminiNotConfigured as ex:
|
||||
raise CopyAborted(str(ex)) from ex
|
||||
|
||||
|
||||
async def save_catchphrases(inputs: CopyInputs, generated: gemini_text.GeneratedCatchphrases) -> dict:
|
||||
place_id = str(inputs.place.place_id)
|
||||
stat = {"tagline": False, "catchphrases": 0, "rejected": [list(r) for r in generated.rejected][:20]}
|
||||
actor = UserInfo(user_id=str(inputs.place.owner_user_id), id="generator", role=1)
|
||||
service = FactService(_fact_crud, _place_crud)
|
||||
values = (("tagline", generated.tagline), ("catchphrases", catchphrase_text.encode(generated.items)))
|
||||
for key, value in values:
|
||||
spec = inputs.schema.get(key)
|
||||
if not (value or "").strip() or not (spec and spec.allow_llm):
|
||||
continue
|
||||
res = await service.upsert_fact(
|
||||
actor, place_id,
|
||||
Req_UpsertFact(
|
||||
key=key, value=value.strip(),
|
||||
source_type=SourceType.LLM, source_url=f"llm:{generated.source or external_api_config.gemini_text_model}",
|
||||
),
|
||||
)
|
||||
if not res.result.success:
|
||||
stat["rejected"].append([key, res.result.desc])
|
||||
elif key == "tagline":
|
||||
stat["tagline"] = True
|
||||
else:
|
||||
stat["catchphrases"] = len(generated.items)
|
||||
LOG.i(f"[copy] place={place_id} 캐치프레이즈 {'O' if stat['tagline'] else 'X'} · 순환 {stat['catchphrases']}개")
|
||||
return stat
|
||||
|
||||
|
||||
async def fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int:
|
||||
"""노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다."""
|
||||
l_err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||
|
||||
@ -13,6 +13,7 @@ from services.llm import provider
|
||||
from services.llm.errors import LlmError
|
||||
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
|
||||
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
|
||||
from services.prompts import catchphrase as catchphrase_prompt
|
||||
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
|
||||
|
||||
|
||||
@ -159,6 +160,90 @@ async def generate_copy(
|
||||
return result
|
||||
|
||||
|
||||
# ── 첫 화면 문구(generate_catchphrases) ─────────────────────────────────
|
||||
@dataclass
|
||||
class GeneratedCatchphrases:
|
||||
tagline: Optional[str] = None
|
||||
items: list[dict] = field(default_factory=list)
|
||||
rejected: list[tuple[str, str]] = field(default_factory=list)
|
||||
source: str = ""
|
||||
|
||||
|
||||
async def generate_catchphrases(
|
||||
place_name: str,
|
||||
facts: list[FactInput],
|
||||
*,
|
||||
records: Optional[list[str]] = None,
|
||||
model: Optional[str] = None,
|
||||
max_retries: int = 2,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> GeneratedCatchphrases:
|
||||
"""고정 캐치프레이즈 하나와 순환 문구 모음 — 문구마다 소개문과 같은 근거 검사를 거친다."""
|
||||
llm = provider.active()
|
||||
if not llm.is_configured():
|
||||
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
|
||||
model = model or llm.DEFAULT_MODEL
|
||||
grounding = list(facts) + [FactInput(key="place_name", label="상호명", value=place_name)]
|
||||
prompt = catchphrase_prompt.build_prompt(place_name, facts, records)
|
||||
|
||||
owns_client = client is None
|
||||
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
||||
try:
|
||||
llm_result = await llm.generate(
|
||||
client, model, prompt=prompt, response_schema=catchphrase_prompt.RESPONSE_SCHEMA,
|
||||
temperature=0.7, max_retries=max_retries,
|
||||
)
|
||||
finally:
|
||||
if owns_client:
|
||||
await client.aclose()
|
||||
|
||||
parsed = llm_result.json or {}
|
||||
result = GeneratedCatchphrases(source=f"{llm.__name__.rsplit('.', 1)[-1]}:{model}")
|
||||
seen: set[str] = set()
|
||||
|
||||
def accept(text: object) -> Optional[str]:
|
||||
line = str(text or "").strip()
|
||||
if not line or line in seen:
|
||||
return None
|
||||
if not (catchphrase_prompt.MIN_LEN <= len(line) <= catchphrase_prompt.MAX_LEN):
|
||||
result.rejected.append((line, f"길이 {len(line)}자"))
|
||||
return None
|
||||
if place_name and place_name in line:
|
||||
result.rejected.append((line, "상호명 포함"))
|
||||
return None
|
||||
ok, reasons = ground_check(line, grounding)
|
||||
if not ok:
|
||||
result.rejected.append((line, " / ".join(reasons)))
|
||||
return None
|
||||
seen.add(line)
|
||||
return line
|
||||
|
||||
result.tagline = accept(parsed.get("tagline"))
|
||||
for text in (parsed.get("general") or [])[: catchphrase_prompt.COUNT_GENERAL]:
|
||||
if line := accept(text):
|
||||
result.items.append({"text": line, "kind": "general"})
|
||||
for row in parsed.get("season") or []:
|
||||
season = (row or {}).get("season")
|
||||
if season in catchphrase_prompt.SEASONS and (line := accept(row.get("text"))):
|
||||
result.items.append({"text": line, "kind": "season", "season": season})
|
||||
for row in parsed.get("month") or []:
|
||||
month = (row or {}).get("month")
|
||||
if isinstance(month, int) and 1 <= month <= 12 and (line := accept(row.get("text"))):
|
||||
result.items.append({"text": line, "kind": "month", "month": month})
|
||||
for row in parsed.get("weather") or []:
|
||||
weather = (row or {}).get("weather")
|
||||
if weather in catchphrase_prompt.WEATHERS and (line := accept(row.get("text"))):
|
||||
result.items.append({"text": line, "kind": "weather", "weather": weather})
|
||||
|
||||
usage = llm_result.usage
|
||||
LOG.i(
|
||||
f"[llm-text] '{place_name}' 첫 화면 문구 — 캐치프레이즈 {'O' if result.tagline else 'X'} · "
|
||||
f"순환 {len(result.items)}개 · 반려 {len(result.rejected)}건 · model={model} · "
|
||||
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── 요약(summarize_text) ──────────────────────────────────────────────────
|
||||
_SUMMARY_CACHE: dict[str, str] = {}
|
||||
_SUMMARY_CACHE_MAX = 500
|
||||
|
||||
84
solution/backend/services/prompts/catchphrase.py
Normal file
84
solution/backend/services/prompts/catchphrase.py
Normal 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)
|
||||
@ -18,6 +18,7 @@ from common.enums import (
|
||||
)
|
||||
from common.logger import LOG
|
||||
from common.template_catalog import TEMPLATES, industry_of, resolve_template_id
|
||||
from services import catchphrase_text
|
||||
from services.intro_summary import summarize_intro
|
||||
from services.stay_guide import nol_stay_guide
|
||||
from services.weather_notes import weather_notes
|
||||
@ -33,6 +34,9 @@ DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads"
|
||||
SITE_HOST_ENV = "SITE_PUBLIC_HOST"
|
||||
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
|
||||
|
||||
_HERO_COPY_KEYS = {"tagline", "catchphrases"}
|
||||
|
||||
|
||||
def _scheme(host: str) -> str:
|
||||
"""localhost 는 http 다."""
|
||||
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
|
||||
@ -596,6 +600,14 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
|
||||
# 요약은 **첫 문장**이다.
|
||||
"summary": _first_sentence(paragraphs[0]) if paragraphs else None,
|
||||
}
|
||||
tagline = _text(next((f["value"] for f in place_facts if f["key"] == "tagline" and f["value"]), ""))
|
||||
if tagline:
|
||||
narrative["tagline"] = tagline.strip()
|
||||
rotating = catchphrase_text.decode(
|
||||
_text(next((f["value"] for f in place_facts if f["key"] == "catchphrases" and f["value"]), ""))
|
||||
)
|
||||
if rotating:
|
||||
narrative["catchphrases"] = {"version": 1, "items": rotating}
|
||||
|
||||
theme = _theme(site, category)
|
||||
|
||||
@ -651,7 +663,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
|
||||
),
|
||||
"kakaoPlaceId": kakao_place_id or None,
|
||||
},
|
||||
"facts": place_facts,
|
||||
"facts": [f for f in place_facts if f["key"] not in _HERO_COPY_KEYS],
|
||||
"units": units,
|
||||
"media": media,
|
||||
"faqs": faqs,
|
||||
|
||||
26
solution/backend/tests/test_catchphrase_text.py
Normal file
26
solution/backend/tests/test_catchphrase_text.py
Normal 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"},
|
||||
]
|
||||
@ -30,6 +30,15 @@ def _patch(monkeypatch, copy):
|
||||
|
||||
monkeypatch.setattr(gemini_text, "generate_copy", _gen)
|
||||
|
||||
async def _phrases(place_name, facts, **kw):
|
||||
return gemini_text.GeneratedCatchphrases(
|
||||
tagline="체크인 오후 세 시, 조용한 집",
|
||||
items=[{"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"},
|
||||
{"text": "봄에는 창을 열어 둡니다", "kind": "season", "season": "봄"}],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gemini_text, "generate_catchphrases", _phrases)
|
||||
|
||||
|
||||
async def _place_with_facts(client, h, n_verified=5):
|
||||
pid = (await client.post("/v1/place", headers=h, json={"name": "카피펜션", "category": 1})).json()["place"]["place_id"]
|
||||
@ -96,7 +105,7 @@ async def test_resume_reuses_completed_job(auth_headers, client, db_engine, monk
|
||||
job = (await client.get(f"/v1/job/{first['job_id']}", headers=h)).json()["job"]
|
||||
assert job["place_id"] == pid
|
||||
assert [(s["id"], s["status"]) for s in job["progress"]["steps"]] == [
|
||||
("prepare", "done"), ("generate", "done"), ("save", "done"), ("faq_fill", "done"),
|
||||
("prepare", "done"), ("generate", "done"), ("save", "done"), ("catchphrase", "done"), ("faq_fill", "done"),
|
||||
]
|
||||
fresh = (await client.post(path, headers=h, json={})).json()
|
||||
assert fresh["job_id"] != first["job_id"]
|
||||
@ -111,7 +120,7 @@ async def test_copy_progress_is_visible_during_generation(auth_headers, client,
|
||||
async def generate(*args, **kwargs):
|
||||
row = await JobQueue().get(started["job_id"])
|
||||
assert row["status"] == JobStatus.RUNNING.value
|
||||
assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending"]
|
||||
assert [step["status"] for step in row["progress"]["steps"]] == ["done", "running", "pending", "pending", "pending"]
|
||||
return _copy()
|
||||
|
||||
monkeypatch.setattr(gemini_text, "generate_copy", generate)
|
||||
@ -303,3 +312,21 @@ async def test_copy_requires_api_key(auth_headers, client, monkeypatch):
|
||||
pid = await _place_with_facts(client, h)
|
||||
r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
|
||||
assert r.json()["result"]["code"] == ErrorType.GENERATOR_NOT_CONFIGURED.value
|
||||
|
||||
|
||||
async def test_copy_writes_one_catchphrase_and_the_rotating_lines(auth_headers, client, db_engine, monkeypatch):
|
||||
_patch(monkeypatch, _copy())
|
||||
h = await auth_headers("catchphrase-owner")
|
||||
pid = await _place_with_facts(client, h)
|
||||
job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"]
|
||||
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
|
||||
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
|
||||
assert job["result"]["catchphrase"]["tagline"] is True
|
||||
assert job["result"]["catchphrase"]["catchphrases"] == 2
|
||||
async with db_engine.begin() as c:
|
||||
rows = dict((await c.execute(
|
||||
text("SELECT key, value FROM place_facts WHERE place_id = :p AND key IN ('tagline', 'catchphrases')"),
|
||||
{"p": uuid.UUID(pid)},
|
||||
)).all())
|
||||
assert rows["tagline"] == "체크인 오후 세 시, 조용한 집"
|
||||
assert rows["catchphrases"] == "짐을 풀면 골목부터 걸어 보세요\n봄 | 봄에는 창을 열어 둡니다"
|
||||
|
||||
@ -377,3 +377,34 @@ async def test_summarize_empty_text_returns_none():
|
||||
"""검증: 빈 문자열."""
|
||||
result = await gt.summarize_text(" ")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── 첫 화면 문구 ───────────────────────────────────────────────────────────
|
||||
def _phrase_payload(body):
|
||||
return {
|
||||
"candidates": [{"content": {"parts": [{"text": json.dumps(body, ensure_ascii=False)}]}, "finishReason": "STOP"}],
|
||||
"usageMetadata": {"promptTokenCount": 800, "candidatesTokenCount": 400},
|
||||
}
|
||||
|
||||
|
||||
async def test_catchphrases_keep_only_grounded_lines_of_the_right_shape():
|
||||
body = {
|
||||
"tagline": "주차하고 바로 쉬는 집",
|
||||
"general": ["짐을 풀면 골목부터 걸어 보세요", "오션뷰 수영장이 있습니다", "짧다", "하조대펜션에서 쉬세요"],
|
||||
"season": [{"season": "봄", "text": "봄에는 창을 열어 둡니다"}, {"season": "장마", "text": "비가 오래 오는 계절입니다"}],
|
||||
"month": [{"month": 1, "text": "새해 첫 아침을 조용히"}, {"month": 13, "text": "없는 달의 문구입니다"}],
|
||||
"weather": [{"weather": "비", "text": "빗소리를 들으며 쉬세요"}],
|
||||
}
|
||||
async with _client(_ok(_phrase_payload(body))) as c:
|
||||
res = await gt.generate_catchphrases("하조대펜션", FACTS, client=c)
|
||||
assert res.tagline == "주차하고 바로 쉬는 집"
|
||||
assert res.items == [
|
||||
{"text": "짐을 풀면 골목부터 걸어 보세요", "kind": "general"},
|
||||
{"text": "봄에는 창을 열어 둡니다", "kind": "season", "season": "봄"},
|
||||
{"text": "새해 첫 아침을 조용히", "kind": "month", "month": 1},
|
||||
{"text": "빗소리를 들으며 쉬세요", "kind": "weather", "weather": "비"},
|
||||
]
|
||||
reasons = dict(res.rejected)
|
||||
assert "오션뷰 수영장이 있습니다" in reasons
|
||||
assert reasons["짧다"].startswith("길이")
|
||||
assert reasons["하조대펜션에서 쉬세요"] == "상호명 포함"
|
||||
|
||||
@ -3,6 +3,7 @@ export const GENERATION_LABELS: Record<string, string> = {
|
||||
prepare: '수집된 사진 분류 및 대체 텍스트 생성',
|
||||
generate: '브랜드 컬러 시스템 및 타이포그래피 조합',
|
||||
save: '확인된 사실만으로 소개 콘텐츠 구성',
|
||||
catchphrase: '첫 화면 캐치프레이즈와 순환 문구 작성',
|
||||
faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비',
|
||||
};
|
||||
|
||||
@ -10,5 +11,6 @@ export const SKIP_REASONS: Record<string, string> = {
|
||||
no_facts: '확인된 정보 없음',
|
||||
not_configured: '생성 서비스 미설정',
|
||||
no_catalog: '공통 질문 대상 업종 아님',
|
||||
not_lodging: '숙박 업종만 해당',
|
||||
generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다',
|
||||
};
|
||||
|
||||
@ -322,10 +322,19 @@ export interface RouteEntry {
|
||||
}
|
||||
|
||||
export interface Narrative {
|
||||
/** 히어로 둘째 줄에서 순환할 문구. */
|
||||
/** 히어로 둘째 줄에서 순환할 문구. 첫째 줄(캐치프레이즈)은 `tagline` 하나로 고정이다. */
|
||||
catchphrases?: {
|
||||
version: 1;
|
||||
items: {text: string; kind?: 'general' | 'season' | 'month' | 'weather'}[];
|
||||
items: {
|
||||
text: string;
|
||||
kind?: 'general' | 'season' | 'month' | 'weather';
|
||||
/** kind=season 일 때 봄·여름·가을·겨울. */
|
||||
season?: string;
|
||||
/** kind=month 일 때 1~12. */
|
||||
month?: number;
|
||||
/** kind=weather 일 때 맑음·흐림·비·눈. */
|
||||
weather?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
heroHeadline?: string;
|
||||
|
||||
@ -46,7 +46,7 @@ it('고택은 섹션 순서와 상관없이 날씨를 첫 화면 바로 아래
|
||||
payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])];
|
||||
const html = renderToStaticMarkup(<App payload={payload} />);
|
||||
const hero = html.indexOf('class="hero"');
|
||||
const brief = html.indexOf('class="wbrief"');
|
||||
const brief = html.indexOf('data-editor-id="weather"');
|
||||
expect(hero).toBeGreaterThan(-1);
|
||||
expect(brief).toBeGreaterThan(hero);
|
||||
expect(html.slice(hero, brief)).not.toContain('class="sec');
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {Idx} from './SectionHead';
|
||||
import {Tabs} from './Tabs';
|
||||
@ -66,6 +67,17 @@ export function Around() {
|
||||
</li>
|
||||
);
|
||||
|
||||
const spot = (place: LocalPlace) => (
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
desc={place.description}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rail.length > 0 && (
|
||||
@ -86,16 +98,14 @@ export function Around() {
|
||||
<section id="walk" aria-labelledby="walk-heading">
|
||||
<Idx id="walk-heading" title="주변 명소" aside={`${spots.length} Spots`} />
|
||||
<Tabs items={rangeTabs(spots)} value={spotRange} onChange={setSpotRange} />
|
||||
<ul className="guide">{shownSpots.slice(0, firstSpots).map((place, i) => item(place, i))}</ul>
|
||||
<div className="kgrid">{shownSpots.slice(0, firstSpots).map(spot)}</div>
|
||||
{shownSpots.length > firstSpots && (
|
||||
<details>
|
||||
<summary className="line">
|
||||
<span>{shownSpots.length - firstSpots}곳 더 보기</span>
|
||||
<span>+</span>
|
||||
</summary>
|
||||
<ul className="guide">
|
||||
{shownSpots.slice(firstSpots).map((place, i) => item(place, i + firstSpots))}
|
||||
</ul>
|
||||
<div className="kgrid">{shownSpots.slice(firstSpots).map(spot)}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {FestivalEntry} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Car} from '../paper/Car';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {Idx} from './SectionHead';
|
||||
@ -34,17 +35,12 @@ export function Festival() {
|
||||
arrows
|
||||
label="계절별 축제"
|
||||
slides={shown.map((f) => (
|
||||
<button type="button" className="fc" onClick={() => setOpen(f)}>
|
||||
{f.imageUrl ? (
|
||||
<span className="ph">
|
||||
<img src={f.imageUrl} alt={f.name} loading="lazy" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="noimg">{f.name}</span>
|
||||
)}
|
||||
<span className="t">{f.name}</span>
|
||||
<span className="d">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
|
||||
</button>
|
||||
<PhotoCard
|
||||
image={f.imageUrl}
|
||||
title={f.name}
|
||||
meta={[f.month, f.location].filter(Boolean).join(' · ')}
|
||||
onClick={() => setOpen(f)}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
@ -29,7 +29,7 @@ export function Hero() {
|
||||
<span className="no" /> 오늘의 문장
|
||||
</p>
|
||||
{(lodging || fixed) && <p className="rot">{lodging ? <HeroCatchphrase /> : fixed}</p>}
|
||||
{lodging && fixed && <p className="m sub">{fixed}</p>}
|
||||
<HeroRotator className="m sub" />
|
||||
<div className="hx">
|
||||
{stay ? (
|
||||
<button type="button" className="line line--ink" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -284,3 +284,7 @@
|
||||
.w4-bigtype .ppl{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
|
||||
.w4-bigtype .amen{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}}
|
||||
.w4-bigtype .lead+:is(.chips,.tabrow){margin-top:20px}
|
||||
.w4-bigtype{--kc-r:0;--kc-tw:500;--kc-ts:17px;--kc-mc:var(--sub);--kc-sub:var(--sub);--kc-px:12px;--kc-pb:14px}
|
||||
.w4-bigtype .kgrid{gap:0;margin-top:0;border-top:1px solid var(--line);width:calc(100% + 1px);clip-path:inset(0 1px 0 0)}
|
||||
.w4-bigtype .kgrid>.kcard{border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
@media (min-width:1024px){.w4-bigtype .kgrid{grid-template-columns:repeat(4,minmax(0,1fr));gap:0}}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
|
||||
const WALK = 80;
|
||||
@ -70,6 +71,17 @@ export function Around() {
|
||||
</button>
|
||||
);
|
||||
|
||||
const spot = (item: LocalPlace) => (
|
||||
<PhotoCard
|
||||
key={item.name}
|
||||
image={item.imageUrl}
|
||||
title={item.name}
|
||||
meta={item.distanceText}
|
||||
desc={item.description}
|
||||
onClick={() => setOpen(item)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rail.length > 0 && (
|
||||
@ -78,7 +90,7 @@ export function Around() {
|
||||
<p className="label">AROUND</p>
|
||||
<h2 id="around-heading">주변 안내</h2>
|
||||
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
|
||||
<div className="guide">{rail.slice(0, HOME_ROWS).map((item) => place(item))}</div>
|
||||
<div className="guide guide--card">{rail.slice(0, HOME_ROWS).map(spot)}</div>
|
||||
<a className="link b-more--c" href="#/area">
|
||||
지역 소개에서 모두 보기
|
||||
</a>
|
||||
@ -95,11 +107,11 @@ export function Around() {
|
||||
</h2>
|
||||
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
|
||||
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
|
||||
<div className="guide">{shownSpots.slice(0, firstSpots).map((item) => place(item))}</div>
|
||||
<div className="guide guide--card">{shownSpots.slice(0, firstSpots).map(spot)}</div>
|
||||
{shownSpots.length > firstSpots && (
|
||||
<details className="fold" key={spotRange}>
|
||||
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary>
|
||||
<div className="guide">{shownSpots.slice(firstSpots).map((item) => place(item))}</div>
|
||||
<div className="guide guide--card">{shownSpots.slice(firstSpots).map(spot)}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {FestivalEntry} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
|
||||
const SEASONS = ['봄', '여름', '가을', '겨울'];
|
||||
@ -40,15 +41,13 @@ export function Festival() {
|
||||
)}
|
||||
<div className="rail">
|
||||
{shown.map((f) => (
|
||||
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
|
||||
{f.imageUrl ? (
|
||||
<img src={f.imageUrl} alt={f.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="noimg">{f.name}</span>
|
||||
)}
|
||||
<b className="nm">{f.name}</b>
|
||||
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={f.name}
|
||||
image={f.imageUrl}
|
||||
title={f.name}
|
||||
meta={[f.month, f.location].filter(Boolean).join(' · ')}
|
||||
onClick={() => setOpen(f)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
@ -26,7 +26,6 @@ export function Hero() {
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [total]);
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const english = place.englishName?.trim();
|
||||
const locality = [place.addressLocality, place.addressRegion].find(Boolean);
|
||||
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||
@ -57,10 +56,10 @@ export function Hero() {
|
||||
{english && <p className="b-hero-en">{english}</p>}
|
||||
<h1 className={english ? 'b-hero-name' : 'b-hero-name b-hero-name--solo'}>{place.name}</h1>
|
||||
{locality && <p className="b-hero-loc">{locality}</p>}
|
||||
{fixed && <p className="b-hero-ko">{fixed}</p>}
|
||||
<p className="b-hero-cp">
|
||||
<HeroCatchphrase />
|
||||
</p>
|
||||
<HeroRotator className="b-hero-ko" />
|
||||
<div className="b-hero-act">
|
||||
{stayBooking ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -345,3 +345,22 @@
|
||||
.w4-boutique[data-page=story] main .b-rule{margin-left:0;margin-right:0}
|
||||
.w4-boutique[data-page=story] main .shell{padding-inline:24px}
|
||||
.w4-boutique[data-page=story] main #reviews{margin-top:var(--sec)}}
|
||||
.w4-boutique{--kc-r:999px 999px 0 0;--kc-ar:4/3;--kc-tf:var(--mj);--kc-tw:400;--kc-ts:17px;--kc-mc:var(--rose);--kc-sub:var(--sub);--kc-soft:var(--bg2)}
|
||||
.w4-boutique .rail>.kcard{flex:0 0 62%;height:auto}
|
||||
.w4-boutique .guide--card{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:28px 14px}
|
||||
.w4-boutique #around .guide--card{grid-template-columns:repeat(3,minmax(0,1fr));gap:20px 10px}
|
||||
.w4-boutique #around .kcard-t{font-size:15px}
|
||||
@media (min-width:1024px){.w4-boutique .rail>.kcard{flex-basis:calc((100% - 48px) / 3)}.w4-boutique .guide--card{grid-template-columns:repeat(3,minmax(0,1fr));gap:36px 24px}}
|
||||
@media (max-width:1023px){
|
||||
.w4-boutique{--aw:min(250px,calc(100vw - 88px));--ah:calc(var(--aw) * 1.12)}
|
||||
.w4-boutique[data-page=home] .b-hd:not(.solid){color:var(--brown)}
|
||||
.w4-boutique .b-hero{height:auto;min-height:0;padding:calc(84px + var(--ah) + 24px) var(--g) 8px;background:transparent;overflow:visible}
|
||||
.w4-boutique .b-hero:before{content:"";position:absolute;top:100px;left:50%;width:var(--aw);height:var(--ah);margin-left:calc(var(--aw) / -2 + 14px);border-radius:999px 999px 0 0;background:var(--bg2)}
|
||||
.w4-boutique .b-hero:after{display:none}
|
||||
.w4-boutique .b-slide{inset:84px auto auto 50%;width:var(--aw);height:var(--ah);margin-left:calc(var(--aw) / -2);border-radius:999px 999px 0 0;overflow:hidden;z-index:1}
|
||||
.w4-boutique .b-hero-copy{position:relative;left:auto;right:auto;bottom:auto;padding:0;color:var(--brown)}
|
||||
.w4-boutique .b-hero-ko{color:var(--ink)}
|
||||
.w4-boutique .b-hero-count{top:calc(84px + var(--ah) - 34px);bottom:auto}
|
||||
.w4-boutique .b-hero--plain{padding-top:120px}
|
||||
.w4-boutique .b-hero--plain:before{display:none}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {useWide} from './useWide';
|
||||
|
||||
@ -71,6 +72,17 @@ export function Around() {
|
||||
</button>
|
||||
);
|
||||
|
||||
const spot = (item: LocalPlace) => (
|
||||
<PhotoCard
|
||||
key={item.name}
|
||||
image={item.imageUrl}
|
||||
title={item.name}
|
||||
meta={item.distanceText}
|
||||
desc={item.description}
|
||||
onClick={() => setOpen(item)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{rail.length > 0 && (
|
||||
@ -81,11 +93,13 @@ export function Around() {
|
||||
<p className="lead">걸어서 닿는 명소와 맛집입니다.</p>
|
||||
<div className="grid3">
|
||||
{rail.slice(0, HOME_ROWS).map((item) => (
|
||||
<button key={item.name} type="button" className="card" onClick={() => setOpen(item)}>
|
||||
<img src={item.imageUrl} alt={item.name} loading="lazy" />
|
||||
<b className="nm clamp">{item.name}</b>
|
||||
{item.distanceText && <span className="ds">{item.distanceText}</span>}
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={item.name}
|
||||
image={item.imageUrl}
|
||||
title={item.name}
|
||||
meta={item.distanceText}
|
||||
onClick={() => setOpen(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<a className="link more" href="#/area">
|
||||
@ -104,11 +118,11 @@ export function Around() {
|
||||
</h2>
|
||||
<p className="lead">걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.</p>
|
||||
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
|
||||
<div className="guide">{shownSpots.slice(0, firstSpots).map((item) => place(item))}</div>
|
||||
<div className="guide guide--card">{shownSpots.slice(0, firstSpots).map(spot)}</div>
|
||||
{shownSpots.length > firstSpots && (
|
||||
<details className="fold">
|
||||
<summary className="link">{shownSpots.length - firstSpots}곳 더 보기</summary>
|
||||
<div className="guide">{shownSpots.slice(firstSpots).map((item) => place(item))}</div>
|
||||
<div className="guide guide--card">{shownSpots.slice(firstSpots).map(spot)}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {FestivalEntry} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
|
||||
const SEASONS = ['봄', '여름', '가을', '겨울'];
|
||||
@ -40,15 +41,13 @@ export function Festival() {
|
||||
)}
|
||||
<div className="rail">
|
||||
{shown.map((f) => (
|
||||
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
|
||||
{f.imageUrl ? (
|
||||
<img src={f.imageUrl} alt={f.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="noimg">{f.name}</span>
|
||||
)}
|
||||
<b className="nm">{f.name}</b>
|
||||
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={f.name}
|
||||
image={f.imageUrl}
|
||||
title={f.name}
|
||||
meta={[f.month, f.location].filter(Boolean).join(' · ')}
|
||||
onClick={() => setOpen(f)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {galleryImages, isSectionEnabled} from '@site/lib/derive';
|
||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||
@ -56,7 +56,8 @@ export function Hero() {
|
||||
<HeroCatchphrase />
|
||||
</p>
|
||||
<h1 className="nm">{place.name}</h1>
|
||||
{sub && <p className="sub">{sub}</p>}
|
||||
{where && <p className="sub">{where}</p>}
|
||||
<HeroRotator className="sub" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -296,3 +296,9 @@
|
||||
.w4-cinema .unit .btns{grid-template-columns:1fr 1fr}}
|
||||
.w4-cinema .wrap>.label:has(+h2),.w4-cinema .book .label,.w4-cinema .rhead>.kicker{display:none}
|
||||
.w4-cinema :is(h1,h2,.big,.hl){font-family:var(--tpl-font-heading)}
|
||||
.w4-cinema{--kc-r:14px;--kc-tw:600;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:rgba(244,239,230,.06)}
|
||||
.w4-cinema .rail>.kcard{flex:0 0 72%;height:auto}
|
||||
.w4-cinema .guide--card{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 14px;border-top:0}
|
||||
.w4-cinema .grid3:has(>.kcard){gap:20px 10px}
|
||||
.w4-cinema .grid3>.kcard .kcard-t{font-size:15px}
|
||||
@media (min-width:1024px){.w4-cinema .rail>.kcard{flex:0 0 calc((100% - 30px) / 4)}.w4-cinema .guide--card,.w4-cinema #walk .guide{grid-template-columns:repeat(4,minmax(0,1fr));gap:32px 16px}.w4-cinema #around .grid3:has(>.kcard){grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {useWide} from './useWide';
|
||||
|
||||
@ -66,18 +67,14 @@ export function Around() {
|
||||
);
|
||||
|
||||
const card = (place: LocalPlace) => (
|
||||
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
|
||||
{place.imageUrl && (
|
||||
<div className="ph">
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bd">
|
||||
<h3>{place.name}</h3>
|
||||
{place.distanceText && <small>{place.distanceText}</small>}
|
||||
{place.description && <p className="clamp">{place.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
desc={place.description}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -94,15 +91,13 @@ export function Around() {
|
||||
</div>
|
||||
<div className="guide guide--3">
|
||||
{rail.slice(0, railMax).map((place) => (
|
||||
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
|
||||
<div className="ph">
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" />
|
||||
</div>
|
||||
<div className="bd">
|
||||
<h3>{place.name}</h3>
|
||||
{place.distanceText && <small>{place.distanceText}</small>}
|
||||
</div>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<a href="#/area" className="more">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -20,7 +20,6 @@ export function Hero() {
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -56,16 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && fixed && <p className="tagline">{fixed}</p>}
|
||||
{chips.length > 0 && (
|
||||
<div className="chips">
|
||||
{chips.map((chip) => (
|
||||
<span key={chip} className="chip">
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -12,20 +12,9 @@ const MOOD_ICON = {
|
||||
눈: SnowIcon,
|
||||
} as const;
|
||||
|
||||
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
function observed(iso: string): string | undefined {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
|
||||
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
|
||||
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
|
||||
}
|
||||
|
||||
export function Weather() {
|
||||
|
||||
@ -447,3 +447,5 @@
|
||||
.w4-coral #about .amen li.off .ic{color:#c2410c}
|
||||
.w4-coral #about .amen li.off{color:var(--sub)}
|
||||
.w4-coral :is(h1,h2,h3){font-family:var(--tpl-font-heading)}
|
||||
.w4-coral{--kc-r:0;--kc-tw:500;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:var(--alt)}
|
||||
.w4-coral .cards2,.w4-coral .guide--3{row-gap:24px}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {SpotIcon} from './Art';
|
||||
import {useWide} from './useWide';
|
||||
@ -70,6 +71,18 @@ export function Around() {
|
||||
</button>
|
||||
);
|
||||
|
||||
const spot = (place: LocalPlace) => (
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
desc={place.description}
|
||||
fallback={<SpotIcon food={false} />}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-pg="home">
|
||||
@ -103,11 +116,11 @@ export function Around() {
|
||||
const shown = spots.filter((p) => inRange(spotRange)(meters(p)));
|
||||
return (
|
||||
<>
|
||||
<div className="guide guide--card">{shown.slice(0, firstSpots).map((p, i) => row(p, i))}</div>
|
||||
<div className="guide guide--card">{shown.slice(0, firstSpots).map(spot)}</div>
|
||||
{shown.length > firstSpots && (
|
||||
<details>
|
||||
<summary className="more">{shown.length - firstSpots}곳 더 보기</summary>
|
||||
<div className="guide guide--card">{shown.slice(firstSpots).map((p, i) => row(p, i + firstSpots))}</div>
|
||||
<div className="guide guide--card">{shown.slice(firstSpots).map(spot)}</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {FestivalEntry} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
|
||||
const SEASONS = ['봄', '여름', '가을', '겨울'];
|
||||
@ -40,15 +41,13 @@ export function Festival() {
|
||||
)}
|
||||
<div className="rail">
|
||||
{shown.map((f) => (
|
||||
<button key={f.name} type="button" className="card" onClick={() => setOpen(f)}>
|
||||
{f.imageUrl ? (
|
||||
<img src={f.imageUrl} alt={f.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="noimg">{f.name}</span>
|
||||
)}
|
||||
<b className="nm">{f.name}</b>
|
||||
<span className="ds">{[f.month, f.location].filter(Boolean).join(' · ')}</span>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={f.name}
|
||||
image={f.imageUrl}
|
||||
title={f.name}
|
||||
meta={[f.month, f.location].filter(Boolean).join(' · ')}
|
||||
onClick={() => setOpen(f)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import {useEffect, useState, type CSSProperties} from 'react';
|
||||
import {PlaceCategory, type WeatherSky} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {HeroRotator, useMainCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||
import {useWeatherNotes} from '@site/lib/use-weather-note';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView, weatherBand, weatherMood} from '@site/lib/derive';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import {Hills, Sky} from './Art';
|
||||
|
||||
const STEP_MS = 35;
|
||||
const ROTATE_MS = 4500;
|
||||
|
||||
export function Pop({text}: {text: string}) {
|
||||
const words = text.split(/\s+/).map((word) => [...word]);
|
||||
@ -32,26 +31,6 @@ export function Pop({text}: {text: string}) {
|
||||
);
|
||||
}
|
||||
|
||||
function useCatchphrase(): string | undefined {
|
||||
const {place, narrative} = useSite();
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const own = (narrative.catchphrases?.items ?? [])
|
||||
.filter((item) => (item.kind ?? 'general') === 'general')
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean);
|
||||
const phrases = own.length > 0 ? own : lodging ? lines : [];
|
||||
const [index, setIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (phrases.length < 2) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
|
||||
}, ROTATE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [phrases.length]);
|
||||
return phrases.length > 0 ? phrases[index % phrases.length] : undefined;
|
||||
}
|
||||
|
||||
function WeatherCard() {
|
||||
const payload = useSite();
|
||||
const weather = useLiveWeather({
|
||||
@ -101,9 +80,8 @@ function WeatherCard() {
|
||||
export function Hero() {
|
||||
const payload = useSite();
|
||||
const {place, narrative} = payload;
|
||||
const phrase = useCatchphrase();
|
||||
const phrase = useMainCatchphrase();
|
||||
const region = [place.addressRegion, place.addressLocality].filter(Boolean).join(' ');
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const stay = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||
const link = bookingLinks(payload)[0];
|
||||
|
||||
@ -111,7 +89,6 @@ export function Hero() {
|
||||
<section className="scene" id="top" data-pg="home" aria-labelledby="hero-heading">
|
||||
<Sky />
|
||||
<div className="hero-copy">
|
||||
{region && <span className="tag">{region}</span>}
|
||||
<h1 id="hero-heading" className="disp">
|
||||
<Pop text={place.name} />
|
||||
</h1>
|
||||
@ -120,7 +97,7 @@ export function Hero() {
|
||||
<Pop text={phrase} />
|
||||
</p>
|
||||
)}
|
||||
{fixed && <p className="sub">{fixed}</p>}
|
||||
<HeroRotator className="sub" />
|
||||
{(stay || link || place.phone) && (
|
||||
<div className="hgo">
|
||||
{stay ? (
|
||||
|
||||
@ -334,3 +334,7 @@
|
||||
@media(max-width:1023px){.w4-graphic #location .btns{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.w4-graphic #location .btns>:first-child{grid-column:1/-1}
|
||||
.w4-graphic #location .btns>*{white-space:nowrap;min-width:0}}
|
||||
.w4-graphic{--kc-r:0;--kc-box-r:22px;--kc-bg:var(--card);--kc-shadow:0 2px 0 rgba(29,43,36,.08);--kc-px:12px;--kc-pb:14px;--kc-tf:var(--k-head);--kc-tw:400;--kc-ts:17px;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:#fff1c9}
|
||||
.w4-graphic .rail>.kcard{flex:0 0 72%;height:auto}
|
||||
.w4-graphic .guide--card{grid-template-columns:repeat(2,minmax(0,1fr));gap:24px 14px}
|
||||
@media (min-width:1024px){.w4-graphic .rail>.kcard{flex:0 0 calc((100% - 48px) / 4)}.w4-graphic .guide--card{grid-template-columns:repeat(4,minmax(0,1fr));gap:32px 16px}}
|
||||
|
||||
31
solution/site/src/layouts/kit/Card.tsx
Normal file
31
solution/site/src/layouts/kit/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
solution/site/src/layouts/kit/card.css
Normal file
13
solution/site/src/layouts/kit/card.css
Normal 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}}
|
||||
@ -1,8 +1,8 @@
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine){--k-pad:20px;--k-wide:640px;--k-r:12px;--k-soft:rgba(127,127,127,.08);--k-line:rgba(127,127,127,.22);--k-head:inherit}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) *{box-sizing:border-box}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{border-radius:20px 20px 0 0}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform{padding:22px var(--k-pad) 8px}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform h3{margin:0 0 16px;font-family:var(--k-head);font-size:21px;font-weight:600}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{border-radius:var(--k-r)}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) :is(.sform,.sheetbody){padding:22px var(--k-pad) 8px}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) :is(.sform,.sheetbody) h3{margin:0 0 16px;font-family:var(--k-head);font-size:21px;font-weight:600}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet .cta,:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform button.cta{display:flex;align-items:center;justify-content:center;width:100%;height:52px;border:0;border-radius:var(--k-r);background:var(--k-ink);color:var(--k-bg);font:inherit;font-size:16px;font-weight:700;cursor:pointer}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform input,:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sform textarea{width:100%;padding:12px 14px;border:1px solid var(--k-line);border-radius:var(--k-r);background:transparent;color:var(--k-ink);font:inherit;font-size:16px}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .car{position:relative}
|
||||
@ -48,10 +48,10 @@
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .car .dots button.on{width:18px;border-radius:999px;background:var(--k-ink)}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) details>summary{list-style:none;cursor:pointer}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) details>summary::-webkit-details-marker{display:none}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet{position:fixed;inset:0;z-index:40}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:24px 16px}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sheet[hidden]{display:none}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbg{position:absolute;inset:0;background:rgba(14,13,10,.52)}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{position:absolute;left:0;right:0;bottom:0;max-width:var(--k-wide);margin:0 auto; max-height:88svh;overflow:auto;background:var(--k-bg);padding:0 0 32px; -webkit-overflow-scrolling:touch}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sbox{position:relative;width:100%;max-width:560px;margin:0 auto; max-height:calc(100svh - 48px);overflow:auto;background:var(--k-bg);padding:0 0 32px; -webkit-overflow-scrolling:touch}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sx{position:sticky;top:0;float:right;width:48px;height:48px;border:0;background:none; font-size:22px;line-height:1;color:var(--k-ink);cursor:pointer;z-index:1}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sin{padding-top:16px}
|
||||
:is(.w4-round,.w4-cinema,.w4-bigtype,.w4-boutique,.w4-graphic,.w4-coral,.w4-minimal,.w4-pine) .sin img{margin:0 0 18px}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {useWide} from './useWide';
|
||||
|
||||
@ -66,18 +67,14 @@ export function Around() {
|
||||
);
|
||||
|
||||
const card = (place: LocalPlace) => (
|
||||
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
|
||||
{place.imageUrl && (
|
||||
<div className="ph">
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bd">
|
||||
<h3>{place.name}</h3>
|
||||
{place.distanceText && <small>{place.distanceText}</small>}
|
||||
{place.description && <p className="clamp">{place.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
desc={place.description}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -94,15 +91,13 @@ export function Around() {
|
||||
</div>
|
||||
<div className="guide guide--3">
|
||||
{rail.slice(0, railMax).map((place) => (
|
||||
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
|
||||
<div className="ph">
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" />
|
||||
</div>
|
||||
<div className="bd">
|
||||
<h3>{place.name}</h3>
|
||||
{place.distanceText && <small>{place.distanceText}</small>}
|
||||
</div>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<a href="#/area" className="more">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -20,7 +20,6 @@ export function Hero() {
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -56,16 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && fixed && <p className="tagline">{fixed}</p>}
|
||||
{chips.length > 0 && (
|
||||
<div className="chips">
|
||||
{chips.map((chip) => (
|
||||
<span key={chip} className="chip">
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -12,20 +12,9 @@ const MOOD_ICON = {
|
||||
눈: SnowIcon,
|
||||
} as const;
|
||||
|
||||
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
function observed(iso: string): string | undefined {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
|
||||
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
|
||||
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
|
||||
}
|
||||
|
||||
export function Weather() {
|
||||
|
||||
@ -450,3 +450,5 @@
|
||||
.w4-minimal #about .amen li.off .ic{color:#c2410c}
|
||||
.w4-minimal #about .amen li.off{color:var(--sub)}
|
||||
.w4-minimal :is(h1,h2,.hl){font-family:var(--tpl-font-heading)}
|
||||
.w4-minimal{--kc-r:0;--kc-tw:400;--kc-mc:var(--sub);--kc-sub:var(--sub);--kc-soft:var(--alt)}
|
||||
.w4-minimal .cards2,.w4-minimal .guide--3{row-gap:28px}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Car} from './Car';
|
||||
import {Sheet} from './Sheet';
|
||||
|
||||
@ -80,11 +81,7 @@ export function Around() {
|
||||
arrows
|
||||
label="주변 안내"
|
||||
slides={rail.map((place) => (
|
||||
<button type="button" className="itemcard" onClick={() => setOpen(place)}>
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
|
||||
<p className="cap">{place.name}</p>
|
||||
{place.distanceText && <p className="cap dim">{place.distanceText}</p>}
|
||||
</button>
|
||||
<PhotoCard image={place.imageUrl} title={place.name} meta={place.distanceText} onClick={() => setOpen(place)} />
|
||||
))}
|
||||
/>
|
||||
</section>
|
||||
@ -104,17 +101,16 @@ export function Around() {
|
||||
걸어서 닿는 곳부터 차로 가는 곳까지 묶었습니다.
|
||||
</p>
|
||||
<RangeTabs places={spots} value={spotRange} onChange={setSpotRange} />
|
||||
<div className="grid2" style={{gap: 14}}>
|
||||
<div className="grid2" style={{gap: '24px 14px'}}>
|
||||
{shownSpots.map((place) => (
|
||||
<button key={place.name} type="button" className="itemcard" onClick={() => setOpen(place)}>
|
||||
{place.imageUrl && (
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
|
||||
)}
|
||||
<p className="cap">
|
||||
<b>{place.name}</b> {place.distanceText && <span className="dim">{place.distanceText}</span>}
|
||||
</p>
|
||||
{place.description && <p className="cap dim clamp">{place.description}</p>}
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
desc={place.description}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {FestivalEntry} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Car} from './Car';
|
||||
import {Sheet} from './Sheet';
|
||||
|
||||
@ -48,19 +49,12 @@ export function Festival() {
|
||||
arrows
|
||||
label="계절별 축제"
|
||||
slides={shown.map((f) => (
|
||||
<button type="button" className="itemcard" onClick={() => setOpen(f)}>
|
||||
{f.imageUrl ? (
|
||||
<img src={f.imageUrl} alt={f.name} loading="lazy" style={{aspectRatio: '3 / 2'}} />
|
||||
) : (
|
||||
<span className="noimg" style={{aspectRatio: '3 / 2'}}>
|
||||
{f.name}
|
||||
</span>
|
||||
)}
|
||||
<p className="cap">
|
||||
<b>{f.name}</b>
|
||||
</p>
|
||||
<p className="cap dim">{[f.month, f.location].filter(Boolean).join(' · ')}</p>
|
||||
</button>
|
||||
<PhotoCard
|
||||
image={f.imageUrl}
|
||||
title={f.name}
|
||||
meta={[f.month, f.location].filter(Boolean).join(' · ')}
|
||||
onClick={() => setOpen(f)}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {isSectionEnabled} from '@site/lib/derive';
|
||||
import {Weather} from './Weather';
|
||||
@ -13,7 +13,6 @@ export function Hero() {
|
||||
.filter((image) => image.alt?.trim())
|
||||
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
|
||||
.slice(0, MAX_SLIDES);
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -41,10 +40,10 @@ export function Hero() {
|
||||
<p className="eyebrow">{place.addressLocality ?? place.addressRegion}</p>
|
||||
)}
|
||||
<h1 className="mj">{place.name}</h1>
|
||||
{fixed && <p className="tagline">{fixed}</p>}
|
||||
<p className="cp">
|
||||
<HeroCatchphrase />
|
||||
</p>
|
||||
<HeroRotator className="tagline" />
|
||||
</div>
|
||||
</section>
|
||||
{isSectionEnabled(payload, 'weather') && (
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState, type ReactNode} from 'react';
|
||||
import type {ChronicleItem, PeopleItem, ReadingItem, SongItem} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {sectionItems, sectionName} from '@site/lib/derive';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from './Sheet';
|
||||
|
||||
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'};
|
||||
@ -60,27 +61,35 @@ export function Story() {
|
||||
{item.year && <span className="r">{item.year}</span>}
|
||||
</button>
|
||||
);
|
||||
const person = (item: PeopleItem) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
className="itemcard"
|
||||
onClick={() =>
|
||||
setOpen({
|
||||
title: item.name,
|
||||
meta: [item.role, item.years].filter(Boolean).join(' · '),
|
||||
body: item.oneLine,
|
||||
image: item.imageUrl,
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.imageUrl && <img src={item.imageUrl} alt={item.name} loading="lazy" style={{aspectRatio: '1 / 1'}} />}
|
||||
<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 person = (item: PeopleItem) => {
|
||||
const show = () =>
|
||||
setOpen({
|
||||
title: item.name,
|
||||
meta: [item.role, item.years].filter(Boolean).join(' · '),
|
||||
body: item.oneLine,
|
||||
image: item.imageUrl,
|
||||
});
|
||||
if (item.imageUrl)
|
||||
return (
|
||||
<PhotoCard
|
||||
key={item.name}
|
||||
image={item.imageUrl}
|
||||
title={item.name}
|
||||
meta={item.role}
|
||||
desc={item.oneLine}
|
||||
ratio="1 / 1"
|
||||
onClick={show}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<button key={item.name} type="button" className="itemcard" onClick={show}>
|
||||
<p className="cap">
|
||||
<b>{item.name}</b> {item.role && <span className="dim">{item.role}</span>}
|
||||
</p>
|
||||
{item.oneLine && <p className="cap dim clamp">{item.oneLine}</p>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const event = (item: ChronicleItem) => (
|
||||
<button
|
||||
key={`${item.year}-${item.title}`}
|
||||
|
||||
@ -255,10 +255,10 @@
|
||||
@supports(padding:max(0px)){
|
||||
.w4p .bar{height:calc(var(--bar) + env(safe-area-inset-bottom)); padding-bottom:calc(9px + env(safe-area-inset-bottom))}
|
||||
}
|
||||
.w4p .sheet{position:fixed;inset:0;z-index:40}
|
||||
.w4p .sheet{position:fixed;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:24px 16px}
|
||||
.w4p .sheet[hidden]{display:none}
|
||||
.w4p .sbg{position:absolute;inset:0;background:rgba(14,13,10,.52)}
|
||||
.w4p .sbox{position:absolute;left:0;right:0;bottom:0;max-width:var(--wide);margin:0 auto; max-height:88svh;overflow:auto;background:var(--paper);padding:0 0 32px; -webkit-overflow-scrolling:touch}
|
||||
.w4p .sbox{position:relative;width:100%;max-width:560px;margin:0 auto; max-height:calc(100svh - 48px);border-radius:8px;overflow:auto;background:var(--paper);padding:0 0 32px; -webkit-overflow-scrolling:touch}
|
||||
.w4p .sx{position:sticky;top:0;float:right;width:48px;height:48px;border:0;background:none; font-size:22px;line-height:1;color:var(--ink);cursor:pointer;z-index:1}
|
||||
.w4p .sin{padding-top:16px}
|
||||
.w4p .sin img{margin:0 0 18px}
|
||||
@ -338,3 +338,4 @@
|
||||
.w4p #w4d-panel .w4d-eq i{background:#8c4a2f}
|
||||
.w4p #w4d-lyrics-box{padding:14px 16px 18px;border-top:1px solid var(--line);font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:15px;line-height:1.95;color:#39352e}
|
||||
.w4p #w4d-panel .w4d-info{line-height:1.45}
|
||||
.w4p{--kc-r:4px;--kc-tw:500;--kc-ts:16.5px;--kc-mc:var(--sub);--kc-sub:var(--sub);--kc-soft:var(--tint)}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {useWide} from './useWide';
|
||||
|
||||
@ -66,18 +67,14 @@ export function Around() {
|
||||
);
|
||||
|
||||
const card = (place: LocalPlace) => (
|
||||
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
|
||||
{place.imageUrl && (
|
||||
<div className="ph">
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bd">
|
||||
<h3>{place.name}</h3>
|
||||
{place.distanceText && <small>{place.distanceText}</small>}
|
||||
{place.description && <p className="clamp">{place.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
desc={place.description}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -18,13 +18,8 @@ export function Hero() {
|
||||
const fixed = narrative.tagline ?? narrative.heroSubline;
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const nearest = [...payload.local.attractions]
|
||||
.filter((spot) => spot.distanceText)
|
||||
.sort((a, b) => (a.distanceMeters ?? Infinity) - (b.distanceMeters ?? Infinity))[0];
|
||||
const sub = fixed ?? (nearest ? `${nearest.name}까지 ${nearest.distanceText}` : undefined);
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -60,16 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && sub && <p className="tagline">{sub}</p>}
|
||||
{chips.length > 0 && (
|
||||
<div className="chips">
|
||||
{chips.map((chip) => (
|
||||
<span key={chip} className="chip">
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -12,20 +12,9 @@ const MOOD_ICON = {
|
||||
눈: SnowIcon,
|
||||
} as const;
|
||||
|
||||
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
function observed(iso: string): string | undefined {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
|
||||
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
|
||||
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
|
||||
}
|
||||
|
||||
export function Weather() {
|
||||
|
||||
@ -450,3 +450,5 @@
|
||||
.w4-pine #location .loc .map{aspect-ratio:auto;height:100%;min-height:320px}
|
||||
.w4-pine #location .loc .bd{align-self:center;padding:28px}
|
||||
.w4-pine #festival .car--multi .slide{flex:0 0 260px}}
|
||||
.w4-pine{--kc-r:14px;--kc-tw:600;--kc-mc:var(--pine);--kc-sub:var(--sub);--kc-soft:var(--alt)}
|
||||
.w4-pine .cards2{row-gap:24px}
|
||||
|
||||
@ -2,6 +2,7 @@ import {useState} from 'react';
|
||||
import type {LocalPlace} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {naverMapSearchUrl} from '@site/lib/format';
|
||||
import {PhotoCard} from '../kit/Card';
|
||||
import {Sheet} from '../paper/Sheet';
|
||||
import {useWide} from './useWide';
|
||||
|
||||
@ -66,18 +67,14 @@ export function Around() {
|
||||
);
|
||||
|
||||
const card = (place: LocalPlace) => (
|
||||
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
|
||||
{place.imageUrl && (
|
||||
<div className="ph">
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div className="bd">
|
||||
<h3>{place.name}</h3>
|
||||
{place.distanceText && <small>{place.distanceText}</small>}
|
||||
{place.description && <p className="clamp">{place.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
desc={place.description}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -94,15 +91,13 @@ export function Around() {
|
||||
</div>
|
||||
<div className="guide guide--3">
|
||||
{rail.slice(0, railMax).map((place) => (
|
||||
<button key={place.name} type="button" className="place" onClick={() => setOpen(place)}>
|
||||
<div className="ph">
|
||||
<img src={place.imageUrl} alt={place.name} loading="lazy" />
|
||||
</div>
|
||||
<div className="bd">
|
||||
<h3>{place.name}</h3>
|
||||
{place.distanceText && <small>{place.distanceText}</small>}
|
||||
</div>
|
||||
</button>
|
||||
<PhotoCard
|
||||
key={place.name}
|
||||
image={place.imageUrl}
|
||||
title={place.name}
|
||||
meta={place.distanceText}
|
||||
onClick={() => setOpen(place)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<a href="#/area" className="more">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
|
||||
import {HeroCatchphrase, HeroRotator} from '@site/sections/HeroCatchphrase';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {bookingActionLabel, bookingLinks, heroFacts, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
|
||||
import {Car} from '../paper/Car';
|
||||
import {BOOK_EVENT} from '../paper/Rooms';
|
||||
import {Weather} from './Weather';
|
||||
@ -20,7 +20,6 @@ export function Hero() {
|
||||
const region = place.addressLocality ?? place.addressRegion;
|
||||
const booking = bookingLinks(payload)[0];
|
||||
const stay = lodging ? stayBookingView(payload) : null;
|
||||
const chips = heroFacts(payload).map((row) => `${row.label} ${row.value}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -56,16 +55,7 @@ export function Hero() {
|
||||
) : (
|
||||
fixed && <p className="hl in">{fixed}</p>
|
||||
)}
|
||||
{lodging && fixed && <p className="tagline">{fixed}</p>}
|
||||
{chips.length > 0 && (
|
||||
<div className="chips">
|
||||
{chips.map((chip) => (
|
||||
<span key={chip} className="chip">
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<HeroRotator className="tagline" />
|
||||
<div className="hero-cta">
|
||||
{stay ? (
|
||||
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
|
||||
|
||||
@ -12,20 +12,9 @@ const MOOD_ICON = {
|
||||
눈: SnowIcon,
|
||||
} as const;
|
||||
|
||||
const OBSERVED = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
function observed(iso: string): string | undefined {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
const parts = Object.fromEntries(OBSERVED.formatToParts(date).map((p) => [p.type, p.value]));
|
||||
return `${parts.month} ${parts.day}일 ${parts.hour}:${parts.minute} 관측`;
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
|
||||
return m ? `${Number(m[1])}월 ${Number(m[2])}일 ${m[3]}:${m[4]} 관측` : undefined;
|
||||
}
|
||||
|
||||
export function Weather() {
|
||||
|
||||
@ -337,3 +337,5 @@
|
||||
.w4-round #about .amen li.on .ic{color:#15803d}
|
||||
.w4-round #about .amen li.off .ic{color:#c2410c}
|
||||
.w4-round #about .amen li.off{color:var(--sub)}
|
||||
.w4-round{--kc-r:var(--r-card);--kc-tw:700;--kc-mc:var(--accent);--kc-sub:var(--sub);--kc-soft:var(--alt)}
|
||||
.w4-round .cards2,.w4-round .guide--3{row-gap:24px}
|
||||
|
||||
@ -11,7 +11,8 @@ export function formatKoreanDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`;
|
||||
const [year, month, day] = date.toLocaleDateString('sv-SE', {timeZone: 'Asia/Seoul'}).split('-').map(Number);
|
||||
return `${year}년 ${month}월 ${day}일`;
|
||||
}
|
||||
|
||||
/** 왜 한글 표기가 아닌가 답변 엔진은 본문에서 YYYY-MM-DD 를 찾아 최신성을 매긴다. */
|
||||
|
||||
@ -41,7 +41,7 @@ export function condition(code: number): string {
|
||||
return WEATHER_CONDITION_BY_CODE[code] ?? '흐림';
|
||||
}
|
||||
|
||||
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */
|
||||
/** 날씨는 열 때마다 바뀌는 값이라 HTML 에 굽지 않는다 — 하이드레이션 뒤에 스냅샷을 띄우고 최신 값으로 갈아 끼운다. */
|
||||
export function useLiveWeather({
|
||||
initial,
|
||||
regionCode,
|
||||
@ -53,7 +53,11 @@ export function useLiveWeather({
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}): LiveWeather | undefined {
|
||||
const [weather, setWeather] = useState<LiveWeather | undefined>(initial);
|
||||
const [weather, setWeather] = useState<LiveWeather | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setWeather((current) => current ?? initial);
|
||||
}, [initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!regionCode || latitude == null || longitude == null) return;
|
||||
|
||||
@ -1,37 +1,127 @@
|
||||
import {useEffect, useState, type CSSProperties} from 'react';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {useEffect, useLayoutEffect, useRef, useState, type CSSProperties} from 'react';
|
||||
import {PlaceCategory, type Narrative} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import './hero-catchphrase.css';
|
||||
|
||||
export function HeroCatchphrase({children}: {children?: string}) {
|
||||
const {place, narrative} = useSite();
|
||||
const lodging = place.category === PlaceCategory.LODGING;
|
||||
const [index, setIndex] = useState(0);
|
||||
const own = (narrative.catchphrases?.items ?? [])
|
||||
.filter((item) => (item.kind ?? 'general') === 'general')
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean);
|
||||
const phrases = own.length > 0 ? own : lines;
|
||||
useEffect(() => {
|
||||
if (!lodging) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % phrases.length);
|
||||
}, 6000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [lodging, phrases.length]);
|
||||
type Item = NonNullable<Narrative['catchphrases']>['items'][number];
|
||||
|
||||
if (!lodging) return children;
|
||||
const text = phrases[index % phrases.length];
|
||||
const ROTATE_MS = 6000;
|
||||
const SEASON_OF_MONTH = ['겨울', '겨울', '봄', '봄', '봄', '여름', '여름', '여름', '가을', '가을', '가을', '겨울'];
|
||||
|
||||
function stableIndex(key: string, size: number): number {
|
||||
let hash = 0;
|
||||
for (const ch of key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return hash % size;
|
||||
}
|
||||
|
||||
export function weatherBucket(condition?: string): string | undefined {
|
||||
if (!condition) return undefined;
|
||||
if (/눈/.test(condition)) return '눈';
|
||||
if (/비|소나기/.test(condition)) return '비';
|
||||
if (/흐림|구름/.test(condition)) return '흐림';
|
||||
if (/맑/.test(condition)) return '맑음';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildCycle(items: Item[], month: number, weather?: string, shuffle = true): string[] {
|
||||
const text = (list: Item[]) => list.map((item) => item.text.trim()).filter(Boolean);
|
||||
const general = text(items.filter((item) => (item.kind ?? 'general') === 'general'));
|
||||
if (shuffle) {
|
||||
for (let i = general.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[general[i], general[j]] = [general[j], general[i]];
|
||||
}
|
||||
}
|
||||
const pick = (list: string[]) => (list.length ? list[Math.floor(Math.random() * list.length)] : undefined);
|
||||
const season = text(items.filter((item) => item.kind === 'season' && item.season === SEASON_OF_MONTH[month - 1]));
|
||||
const monthly = text(items.filter((item) => item.kind === 'month' && Number(item.month) === month));
|
||||
const weathered = weather ? text(items.filter((item) => item.kind === 'weather' && item.weather === weather)) : [];
|
||||
const out: string[] = [];
|
||||
let g = 0;
|
||||
const nextGeneral = () => general[g++ % Math.max(general.length, 1)];
|
||||
for (let round = 0; round < Math.max(1, Math.ceil(general.length / 3)); round += 1) {
|
||||
out.push(nextGeneral(), shuffle ? pick(season) ?? '' : season[0] ?? '', nextGeneral(), shuffle ? pick(monthly) ?? '' : monthly[0] ?? '', nextGeneral());
|
||||
if (weathered.length) out.push(shuffle ? pick(weathered) ?? '' : weathered[0]);
|
||||
}
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
export function useMainCatchphrase(fallback?: string): string | undefined {
|
||||
const {place, narrative, site} = useSite();
|
||||
if (place.category !== PlaceCategory.LODGING) return undefined;
|
||||
const own = (narrative.tagline ?? narrative.heroSubline ?? fallback)?.trim();
|
||||
return own || lines[stableIndex(site.siteId || place.name, lines.length)];
|
||||
}
|
||||
|
||||
export function HeroCatchphrase({children}: {children?: string}) {
|
||||
const text = useMainCatchphrase(children);
|
||||
if (!text) return children;
|
||||
return (
|
||||
<span className="hero-catchphrase">
|
||||
{children && <span className="hero-catchphrase-fixed">{children}</span>}
|
||||
<span className="w4d-line w4d-line--in" key={index}>
|
||||
{text.split(/\s+/).map((word, i) => (
|
||||
<span key={i}>{i > 0 && ' '}<span className="w4d-w"><span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span></span></span>
|
||||
))}
|
||||
</span>
|
||||
{text.split(/\s+/).map((word, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ' '}
|
||||
<span className="w4d-w">
|
||||
<span style={{'--w4d-d': `${i * 55}ms`} as CSSProperties}>{word}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroRotator({className}: {className?: string}) {
|
||||
const {place, narrative, local} = useSite();
|
||||
const box = narrative.catchphrases;
|
||||
const items = box?.items ?? [];
|
||||
const month = new Date().getMonth() + 1;
|
||||
const [cycle, setCycle] = useState(() => buildCycle(items, month, weatherBucket(local.weather?.condition), false));
|
||||
const [index, setIndex] = useState(0);
|
||||
const [minHeight, setMinHeight] = useState<number>();
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCycle(buildCycle(box?.items ?? [], new Date().getMonth() + 1, weatherBucket(local.weather?.condition)));
|
||||
}, [box, local.weather?.condition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cycle.length < 2) return;
|
||||
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden && !motion.matches) setIndex((value) => (value + 1) % cycle.length);
|
||||
}, ROTATE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [cycle.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
const width = node.getBoundingClientRect().width;
|
||||
if (!width) return;
|
||||
const probe = node.cloneNode(false) as HTMLElement;
|
||||
probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;min-height:0;width:${width}px`;
|
||||
node.parentNode?.appendChild(probe);
|
||||
let max = 0;
|
||||
for (const line of cycle) {
|
||||
probe.textContent = line;
|
||||
max = Math.max(max, probe.getBoundingClientRect().height);
|
||||
}
|
||||
probe.remove();
|
||||
setMinHeight(Math.ceil(max));
|
||||
};
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [cycle]);
|
||||
|
||||
if (place.category !== PlaceCategory.LODGING || cycle.length === 0) return null;
|
||||
return (
|
||||
<p ref={ref} className={['hero-rotator', className].filter(Boolean).join(' ')} style={{minHeight}} aria-live="off">
|
||||
<span className="hero-rotator-line" key={`${index}-${cycle[index % cycle.length]}`}>
|
||||
{cycle[index % cycle.length]}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/** 오늘의 날씨. */
|
||||
import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react';
|
||||
import type {WeatherSky} from '@o2o/shared';
|
||||
import type {WeatherSky, WeatherSnapshot} from '@o2o/shared';
|
||||
import {useSite} from '@site/lib/site-context';
|
||||
import {useLiveWeather} from '@site/lib/use-live-weather';
|
||||
import {useLiveWeather, type LiveWeather} from '@site/lib/use-live-weather';
|
||||
import {useWeatherNotes} from '@site/lib/use-weather-note';
|
||||
import {weatherBand, weatherMood} from '@site/lib/derive';
|
||||
import {Card, Section} from '@site/lib/ui';
|
||||
@ -16,6 +16,20 @@ function observedText(iso: string | undefined): string | undefined {
|
||||
return `${Number(month)}월 ${Number(day)}일 ${hour}:${minute} 관측`;
|
||||
}
|
||||
|
||||
/** 하늘 문구는 아홉 갈래(WeatherSky), 기온 문구는 기온대로 고른다. */
|
||||
export function weatherLineSources(weather: LiveWeather | undefined, notes: WeatherSnapshot | undefined) {
|
||||
const mood = weatherMood(weather?.condition ?? '');
|
||||
const band = weatherBand(weather?.temperature ?? 0);
|
||||
const condition = (weather?.condition ?? '') as WeatherSky;
|
||||
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
|
||||
return {
|
||||
sky: notes?.noteSets?.[skyKey],
|
||||
skyFallback: notes?.notes?.[mood] ?? weather?.note,
|
||||
temp: notes?.tempNoteSets?.[band],
|
||||
tempFallback: notes?.tempNotes?.[band],
|
||||
};
|
||||
}
|
||||
|
||||
export function WeatherSection() {
|
||||
const payload = useSite();
|
||||
const weather = useLiveWeather({
|
||||
@ -27,15 +41,9 @@ export function WeatherSection() {
|
||||
|
||||
const mood = weatherMood(weather?.condition ?? '');
|
||||
const band = weatherBand(weather?.temperature ?? 0);
|
||||
const notes = payload.local.weather;
|
||||
// 하늘 문구는 아홉 갈래로 고른다(WeatherSky).
|
||||
const condition = (weather?.condition ?? '') as WeatherSky;
|
||||
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
|
||||
const sources = weatherLineSources(weather, payload.local.weather);
|
||||
/* 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다. */
|
||||
const [line, bandLine] = useWeatherNotes(
|
||||
notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note,
|
||||
notes?.tempNoteSets?.[band], notes?.tempNotes?.[band],
|
||||
);
|
||||
const [line, bandLine] = useWeatherNotes(sources.sky, sources.skyFallback, sources.temp, sources.tempFallback);
|
||||
if (!weather) return null;
|
||||
const observed = observedText(weather.observedAt);
|
||||
const SkyIcon = MOOD_ICON[mood];
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
.hero-catchphrase { display: block; }
|
||||
.hero-catchphrase-fixed { display: block; }
|
||||
.hero-catchphrase .w4d-line { display: block; margin-top: 0.5rem; min-height: 1.5em; word-break: keep-all; }
|
||||
.hero-catchphrase { display: block; word-break: keep-all; }
|
||||
.hero-catchphrase .w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; }
|
||||
.hero-catchphrase .w4d-w > span { display: inline-block; animation: lodging-word-in 0.45s both; animation-delay: var(--w4d-d); }
|
||||
.hero-rotator { word-break: keep-all; }
|
||||
.hero-rotator-line { display: block; animation: hero-rotator-in 0.6s both; }
|
||||
@keyframes lodging-word-in {
|
||||
from { opacity: 0; transform: translateY(100%); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-catchphrase .w4d-w > span { animation: none; }
|
||||
@keyframes hero-rotator-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-catchphrase .w4d-w > span, .hero-rotator-line { animation: none; }
|
||||
}
|
||||
|
||||
@ -1,26 +1,65 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {PlaceCategory} from '@o2o/shared';
|
||||
import {PlaceCategory, type SitePayload} from '@o2o/shared';
|
||||
import {SiteProvider} from '@site/lib/site-context';
|
||||
import {LayoutProvider} from '@site/lib/layout';
|
||||
import {LAYOUTS} from '@site/layouts';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import lines from '@site/lib/lodging-catchphrases.json';
|
||||
import {HeroCatchphrase} from './HeroCatchphrase';
|
||||
import {buildCycle, HeroCatchphrase, HeroRotator, weatherBucket} from './HeroCatchphrase';
|
||||
|
||||
it('keeps the original lodging tagline in the static HTML', () => {
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={MOONLIGHT_STAY_PAYLOAD}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>고유문구</HeroCatchphrase></LayoutProvider></SiteProvider>);
|
||||
expect(html).toContain('<span class="hero-catchphrase-fixed">고유문구</span>');
|
||||
expect(html).toContain('w4d-line--in');
|
||||
const text = html.replace(/<[^>]+>/g, '');
|
||||
expect(text).toContain('고유문구' + lines[0]);
|
||||
const text = (html: string) => html.replace(/<[^>]+>/g, '');
|
||||
const render = (payload: SitePayload, node: React.ReactNode) =>
|
||||
renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}>{node}</LayoutProvider></SiteProvider>);
|
||||
|
||||
it('shows the one fixed catchphrase and nothing that rotates in its place', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.narrative.tagline = '담 너머 백 년 된 집';
|
||||
expect(text(render(payload, <HeroCatchphrase />))).toBe('담 너머 백 년 된 집');
|
||||
});
|
||||
|
||||
it('does not add lodging copy or controls to another category', () => {
|
||||
it('falls back to one common line when the place has no catchphrase', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
delete payload.narrative.tagline;
|
||||
delete payload.narrative.heroSubline;
|
||||
expect(lines).toContain(text(render(payload, <HeroCatchphrase />)));
|
||||
});
|
||||
|
||||
it('does not add lodging copy to another category', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
payload.place.category = PlaceCategory.CAFE;
|
||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><HeroCatchphrase>카페 소개</HeroCatchphrase></LayoutProvider></SiteProvider>);
|
||||
expect(html).toBe('카페 소개');
|
||||
expect(render(payload, <HeroCatchphrase>카페 소개</HeroCatchphrase>)).toBe('카페 소개');
|
||||
});
|
||||
|
||||
it('leaves the rotating line out when there is nothing to rotate', () => {
|
||||
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
|
||||
delete payload.narrative.catchphrases;
|
||||
expect(render(payload, <HeroRotator />)).toBe('');
|
||||
});
|
||||
|
||||
it('rotates general, season, general, month, general, weather like the stay mockup', () => {
|
||||
const cycle = buildCycle(
|
||||
[
|
||||
{text: '일반1', kind: 'general'},
|
||||
{text: '일반2', kind: 'general'},
|
||||
{text: '일반3', kind: 'general'},
|
||||
{text: '가을 문구', kind: 'season', season: '가을'},
|
||||
{text: '봄 문구', kind: 'season', season: '봄'},
|
||||
{text: '9월 문구', kind: 'month', month: 9},
|
||||
{text: '비 문구', kind: 'weather', weather: '비'},
|
||||
],
|
||||
9,
|
||||
'비',
|
||||
false,
|
||||
);
|
||||
expect(cycle).toEqual(['일반1', '가을 문구', '일반2', '9월 문구', '일반3', '비 문구']);
|
||||
});
|
||||
|
||||
it('reads the sky into the four weather buckets', () => {
|
||||
expect(weatherBucket('구름많음')).toBe('흐림');
|
||||
expect(weatherBucket('비/눈')).toBe('눈');
|
||||
expect(weatherBucket('소나기')).toBe('비');
|
||||
expect(weatherBucket('맑음')).toBe('맑음');
|
||||
});
|
||||
|
||||
it('ships ten distinct short phrases', () => {
|
||||
|
||||
@ -4,7 +4,7 @@ import {SiteProvider} from '@site/lib/site-context';
|
||||
import {LayoutProvider} from '@site/lib/layout';
|
||||
import {LAYOUTS} from '@site/layouts';
|
||||
import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay';
|
||||
import {WeatherSection} from './WeatherSection';
|
||||
import {WeatherSection, weatherLineSources} from './WeatherSection';
|
||||
import type {WeatherSnapshot} from '@o2o/shared';
|
||||
|
||||
function render(weather?: WeatherSnapshot) {
|
||||
@ -13,29 +13,33 @@ function render(weather?: WeatherSnapshot) {
|
||||
return renderToStaticMarkup(<SiteProvider payload={payload}><LayoutProvider value={LAYOUTS.basic}><WeatherSection /></LayoutProvider></SiteProvider>);
|
||||
}
|
||||
|
||||
it('uses the first sky and temperature lines for deterministic HTML', () => {
|
||||
it('keeps the weather out of the baked HTML — it is filled in the browser', () => {
|
||||
const html = render({temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄']}});
|
||||
expect(html).toBe('');
|
||||
});
|
||||
|
||||
it('picks sky lines by condition and temperature lines by band', () => {
|
||||
const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}};
|
||||
expect(render(weather)).toContain('맑음 첫 줄');
|
||||
expect(render(weather)).toContain('더위 첫 줄');
|
||||
expect(render(weather)).not.toContain('다음 줄');
|
||||
expect(render(weather)).toBe(render(weather));
|
||||
const sources = weatherLineSources(weather, weather);
|
||||
expect(sources.sky).toEqual(['맑음 첫 줄', '다음 줄']);
|
||||
expect(sources.temp).toEqual(['더위 첫 줄']);
|
||||
});
|
||||
|
||||
it('uses condition-specific notes for fog, showers and thunderstorms', () => {
|
||||
const weather = {temperature: 20, observedAt: '2026-09-15T12:00', condition: '보통 소나기', noteSets: {'보통 소나기': ['소나기 첫 줄']}, tempNoteSets: {'선선': ['선선 첫 줄']}};
|
||||
expect(render(weather)).toContain('소나기 첫 줄');
|
||||
expect(weatherLineSources(weather, weather).sky).toEqual(['소나기 첫 줄']);
|
||||
});
|
||||
|
||||
it('uses condition-specific notes for the rain subtypes', () => {
|
||||
const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '강한 착빙성 비', noteSets: {'강한 착빙성 비': ['어는비 첫 줄'], '보통 비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}};
|
||||
expect(render(weather)).toContain('어는비 첫 줄');
|
||||
expect(render(weather)).not.toContain('일반 비 첫 줄');
|
||||
expect(weatherLineSources(weather, weather).sky).toEqual(['어는비 첫 줄']);
|
||||
});
|
||||
|
||||
it('preserves legacy single notes', () => {
|
||||
const html = render({temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}});
|
||||
expect(html).toContain('기존 비 안내');
|
||||
expect(html).toContain('겉옷 안내');
|
||||
const weather = {temperature: 15, observedAt: '2026-09-15T12:00', condition: '비', notes: {'비': '기존 비 안내'}, tempNotes: {'쌀쌀': '겉옷 안내'}};
|
||||
const sources = weatherLineSources(weather, weather);
|
||||
expect(sources.skyFallback).toBe('기존 비 안내');
|
||||
expect(sources.tempFallback).toBe('겉옷 안내');
|
||||
});
|
||||
|
||||
it('does not invent weather when an observation is missing', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user