o2o-site-AEO/solution/site/scripts/mockup/build_pension.py
Mina Choi b78486b845 Merge commit '46171b4f24f2ddc3b9212b9ad6769925bcaceb03' into feature/site-features-and-mockup
# Conflicts:
#	.env.example
#	solution/backend/common/enums.py
#	solution/backend/requirements.txt
#	solution/backend/services/site_payload.py
#	solution/backend/services/snapshot.py
#	solution/backend/services/weather_notes.json
#	solution/shared/src/types/site-payload.ts
#	solution/site/src/layouts/editorial/Shell.tsx
#	solution/site/src/lib/use-live-weather.ts
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/SiteFooter.tsx
#	solution/site/src/sections/WeatherSection.tsx
#	solution/site/src/sections/index.ts
2026-09-18 09:44:10 +09:00

729 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""`/s/stay2` — 같은 내용, **펜션 공식 사이트** 레이아웃.
첫 판은 Airbnb 식 OTA 상세였다(2026-09-16). 그건 숙소를 **비교하러 온** 사람의 화면이고,
공식 사이트는 이미 이 집을 보러 온 사람의 화면이라 장르가 다르다 — 그래서 다시 만들었다.
가져온 것
· 구조 — single-property 부티크/빌라 템플릿 계열(풀블리드 사진 → 짧은 카피 → 객실 →
정보 → 큰 예약 CTA). 목록형 OTA 가 아니라 한 채를 소개하는 흐름이다.
· 기존 디자인 토큰(갱지 바탕·명조)은 쓰지 않는다. 흰 바탕 · 얇고 자간 넓은 표제 ·
색은 사진만. 국내 스테이 공식 사이트의 관용 문법이다.
· 야놀자·여기어때·스테이폴리오를 베끼지 않는다 — 민사 10억 선례가 non-goal 이다(PRODUCT.md 6절).
★ 심미와 효율을 위아래로 나눈다: 위는 사진·여백(브랜드), 아래는 접힌 정보(밀도).
★ `/s/stay` 를 건드리지 않는다. 이 스크립트는 stay2 만 만든다.
"""
import html as H
import json
import re
from pathlib import Path
SP = Path(__file__).parent
OUT = SP / "build" / "stay2.html"
PAYLOAD = SP / "stay-payload-new.json"
def esc(value) -> str:
return H.escape(str(value or ""), quote=True)
def img_url(url: str) -> str:
"""미러 사진은 stay2 자기 디렉토리에서 준다 — stay 를 지워도 안 깨진다."""
return re.sub(r"^/assets/mirror/", "/s/stay2/img/mirror/", url or "")
def fact_map(facts) -> dict:
out = {}
for f in facts:
if f.get("scope") != "place":
continue
value = (f.get("value") or "").strip()
if f.get("type") == "bool":
value = "가능" if value == "true" else "불가" if value == "false" else value
if (f.get("label") or "").endswith("여부"):
value = "있음" if value == "가능" else "없음" if value == "불가" else value
out[f["key"]] = {"label": f.get("label") or f["key"], "value": value,
"note": (f.get("summary") or "").strip(), "bool": f.get("type") == "bool"}
return out
def section_items(payload, section_id):
for s in payload["theme"]["sections"]:
if s["id"] == section_id and s.get("data"):
try:
return json.loads(s["data"]).get("items") or []
except Exception:
return []
return []
# ── 조각들 ────────────────────────────────────────────────────────────────────
def video_ids(payload) -> list:
"""유튜브 Shorts id. Shorts 는 9:16 이라 모바일 히어로와 비율이 맞는다."""
out = []
for item in section_items(payload, "video"):
m = re.search(r"(?:shorts/|watch\?v=|youtu\.be/|embed/)([A-Za-z0-9_-]{6,})", item.get("url") or "")
if m:
out.append({"id": m.group(1), "caption": item.get("caption") or ""})
return out
def hero(payload) -> str:
shots = ([m for m in payload["media"] if not m.get("unitId")] or payload["media"])[:5]
place = payload["place"]
where = (place.get("addressLocality") or place.get("addressRegion") or "").strip()
lines = [c["text"] for c in (payload["narrative"].get("catchphrases", {}).get("items") or [])
if (c.get("kind") or "general") == "general"][:24]
films = video_ids(payload)
layers = "".join(
f'<img class="{"on" if i == 0 else ""}" src="{esc(img_url(m["url"]))}" '
f'alt="{esc(m.get("alt") or place["name"])}" loading="{"eager" if i == 0 else "lazy"}">'
for i, m in enumerate(shots)
)
return f"""
<header class="hero">
<div class="stage">{layers}</div>
<div class="film" id="film" aria-hidden="true"></div>
<span class="veil"></span>
<div class="copy">
<p class="eyebrow">{esc(where or "STAY")}</p>
<h1>{esc(place["name"])}</h1>
<p class="say" id="say">{esc(lines[0] if lines else "")}</p>
</div>
<a class="down" href="#about" aria-label="아래로"><i></i></a>
<script type="application/json" id="lines">{json.dumps(lines, ensure_ascii=False)}</script>
<script type="application/json" id="films">{json.dumps(films, ensure_ascii=False)}</script>
</header>
<nav class="top" id="topbar">
<span class="name">{esc(place["name"])}</span>
<a class="book" href="#book">예약</a>
</nav>"""
def film_block(payload) -> str:
films = video_ids(payload)
if not films:
return ""
cards = []
for i, f in enumerate(films):
uid = f"vid-{i}"
cards.append(
f'<li><button class="shotcard film-card" type="button" data-modal="{uid}">'
f'<img src="https://i.ytimg.com/vi/{esc(f["id"])}/hqdefault.jpg" alt="" loading="lazy">'
f'<span class="grad"></span><span class="play" aria-hidden="true"></span>'
f'<span class="cap"><b>{esc(f["caption"])}</b></span></button>'
f'<template id="{uid}"><h4>{esc(f["caption"])}</h4>'
f'<div class="tube"><iframe src="https://www.youtube.com/embed/{esc(f["id"])}?playsinline=1&amp;rel=0"'
f' title="{esc(f["caption"])}" allow="accelerometer; autoplay; encrypted-media; picture-in-picture"'
f' allowfullscreen loading="lazy"></iframe></div></template></li>'
)
return f'<h3>영상 <span>{len(films)}</span></h3><ul class="shots2 films">{"".join(cards)}</ul>'
def intro_block(payload) -> str:
about = (payload["narrative"].get("about") or [])[:2]
body = "".join(f'<p class="lead">{esc(t)}</p>' for t in about)
return f"""
<section class="sec">
<p class="eyebrow">About</p>
<h2>{esc(payload["narrative"].get("summary") or "")}</h2>
{body}
</section>"""
def gallery_block(payload) -> str:
shots = [m for m in payload["media"] if not m.get("unitId")][1:7]
if not shots:
return ""
cells = "".join(
f'<li><img src="{esc(img_url(m["url"]))}" alt="{esc(m.get("alt") or "")}" loading="lazy"></li>'
for m in shots
)
return f'<ul class="duo gal">{cells}</ul>'
def amenities(facts) -> str:
keys = ["parking", "wifi", "cooking_allowed", "smoking", "pickup_service", "barbecue"]
rows = [facts[k] for k in keys if k in facts and facts[k]["bool"]]
rows += [v for k, v in facts.items() if v["bool"] and k not in keys]
if not rows:
return ""
rows.sort(key=lambda r: r["value"] not in ("가능", "있음"))
cells = "".join(
f'<li class="{"" if r["value"] in ("가능", "있음") else "off"}">'
f'<b>{esc(r["label"].replace(" 가능", "").replace(" 여부", ""))}</b>'
f'<span>{esc(r["value"])}</span></li>'
for r in rows
)
return f'<h3>시설</h3><ul class="amen">{cells}</ul>'
def rules(facts) -> str:
order = ["check_in", "check_out", "cancel_policy", "pet_allowed", "extra_person_fee"]
rows = [facts[k] for k in order if k in facts and facts[k]["value"]]
if not rows:
return ""
items = "".join(
f'<div><dt>{esc(r["label"])}</dt><dd>{esc(r["value"])}'
f'{f"<em>{esc(r['note'])}</em>" if r["note"] else ""}</dd></div>'
for r in rows
)
return f'<h3>예약 전 확인</h3><dl class="rows">{items}</dl>'
def units_block(payload) -> str:
by_id = {m["mediaId"]: m for m in payload["media"]}
cards = []
for unit in payload["units"]:
shots = [by_id[i] for i in unit.get("mediaIds", []) if i in by_id]
specs, intro = [], ""
for f in unit.get("facts", []):
key, value = f.get("key"), (f.get("value") or "").strip()
if key in ("standard_capacity", "max_capacity", "bed_type", "room_size") and value:
specs.append(f"<li>{esc(value)}</li>")
if key in ("room_intro", "description") and value and not intro:
intro = value
name = re.sub(r"\(.*\)$", "", unit["name"]).strip() or unit["name"]
sub = (re.search(r"\((.*)\)$", unit["name"]) or [None, ""])[1]
shot = (f'<div class="shot"><img src="{esc(img_url(shots[0]["url"]))}" '
f'alt="{esc(shots[0].get("alt") or unit["name"])}" loading="lazy"></div>') if shots else ""
rest = "".join(
f'<li><img src="{esc(img_url(m["url"]))}" alt="" loading="lazy"></li>' for m in shots[1:3]
)
cards.append(f"""
<article class="room">
{shot}
<div class="body">
<h3>{esc(name)}</h3>
<p class="sub">{esc(sub)}</p>
<ul class="specs">{''.join(specs)}</ul>
{f'<p>{esc(intro)}</p>' if intro else ''}
</div>
{f'<ul class="duo">{rest}</ul>' if rest else ''}
</article>""")
return "".join(cards)
def faq_block(payload) -> str:
faqs = [f for f in payload["faqs"] if (f.get("question") or "").strip()]
if not faqs:
return ""
items = "".join(
f'<details><summary>{esc(f["question"])}</summary><p>{esc(f.get("answer"))}</p></details>'
for f in faqs
)
return (f'<h3>자주 묻는 질문 <span>{len(faqs)}</span>'
f'<button class="seeall" type="button" data-all>더보기 ›</button></h3>'
f'<div class="acc" data-more="6">{items}</div>')
def places_block(payload) -> str:
groups = [("eat", "맛집", payload["local"].get("restaurants") or []),
("see", "명소", payload["local"].get("attractions") or [])]
groups = [g for g in groups if g[2]]
if not groups:
return ""
cards, chips = [], ['<button class="chip on" type="button" data-filter="all">전체</button>']
for tag, label, rows in groups:
chips.append(f'<button class="chip" type="button" data-filter="{tag}">{label} {len(rows)}</button>')
for i, r in enumerate(rows):
img = img_url(r.get("imageUrl") or "")
uid = f"{tag}-{i}"
cards.append(
f'<li data-kind="{tag}"><button class="shotcard" type="button" data-modal="{uid}">'
+ (f'<img src="{esc(img)}" alt="" loading="lazy">' if img else '<span class="noimg"></span>')
+ '<span class="grad"></span><span class="cap">'
+ f'<em>{esc(r.get("distanceText") or label)}</em><b>{esc(r.get("name"))}</b></span></button>'
+ f'<template id="{uid}"><h4>{esc(r.get("name"))}</h4>'
+ f'<p class="meta">{esc(r.get("distanceText") or "")}'
+ f'{" · " + esc(r.get("address")) if r.get("address") else ""}</p>'
+ (f'<img src="{esc(img)}" alt="">' if img else "")
+ f'<p>{esc(r.get("description") or "")}</p>'
+ f'<a class="out" href="https://map.naver.com/p/search/{esc(r.get("searchQuery") or r.get("name"))}"'
+ ' target="_blank" rel="noopener noreferrer nofollow">지도에서 보기</a></template></li>'
)
total = sum(len(g[2]) for g in groups)
return (f'<h3>가까운 곳 <span>{total}</span>'
f'<button class="seeall" type="button" data-all>더보기 ›</button></h3>'
f'<div class="chips">{"".join(chips)}</div>'
f'<ul class="shots2" data-more="4" id="nearby">{"".join(cards)}</ul>')
def spots_row(payload) -> str:
"""정사각 사진에 라벨만 얹은 줄 — 레퍼런스 1 '국내 인기 여행지'."""
rows = [r for r in (payload["local"].get("attractions") or []) if r.get("imageUrl")][:6]
if not rows:
return ""
cells = "".join(
f'<li><button class="tile" type="button" data-modal="see-{i}">'
f'<img src="{esc(img_url(r["imageUrl"]))}" alt="" loading="lazy">'
f'<span class="grad"></span><b>{esc(r.get("name"))}</b></button></li>'
for i, r in enumerate(rows)
)
return f'<h3>걸어서 닿는 곳</h3><ul class="tiles">{cells}</ul>'
def festival_block(payload) -> str:
rows = payload["local"].get("festivals") or []
if not rows:
return ""
cards = []
for i, r in enumerate(rows):
img = img_url(r.get("imageUrl") or "")
when = " ".join(x for x in [r.get("season") or "", r.get("month") or ""] if x)
cards.append(
f'<li><button class="shotcard" type="button" data-modal="fes-{i}">'
+ (f'<img src="{esc(img)}" alt="" loading="lazy">' if img else '<span class="noimg"></span>')
+ '<span class="grad"></span><span class="cap">'
+ f'<em>{esc(when)}</em><b>{esc(r.get("name"))}</b></span></button>'
+ f'<template id="fes-{i}"><h4>{esc(r.get("name"))}</h4>'
+ f'<p class="meta">{esc(when)}{" · " + esc(r.get("location")) if r.get("location") else ""}</p>'
+ (f'<img src="{esc(img)}" alt="">' if img else "")
+ f'<p>{esc(r.get("description") or "")}</p>'
+ f'<a class="out" href="https://map.naver.com/p/search/{esc(r.get("searchQuery") or r.get("name"))}"'
+ ' target="_blank" rel="noopener noreferrer nofollow">지도에서 보기</a></template></li>'
)
return (f'<h3>계절별 축제 <span>{len(rows)}</span>'
f'<button class="seeall" type="button" data-all>더보기 ›</button></h3>'
f'<ul class="shots2" data-more="4">{"".join(cards)}</ul>')
def itinerary_block(payload) -> str:
items = section_items(payload, "itinerary")
if not items:
items = payload["local"].get("itineraries") or []
cards = []
for item in items:
days = item.get("days") or [{"stops": item.get("stops") or []}]
stops = [s for d in days for s in (d.get("stops") or [])]
if not stops:
continue
lines = "".join(f"<li>{esc(s.get('name'))}</li>" for s in stops[:12])
cards.append(
f'<details><summary><b>{esc(item.get("name"))}</b>'
f'<em>{esc(item.get("duration") or "")} · {len(stops)}곳</em></summary>'
f'<ol class="stops">{lines}</ol></details>'
)
if not cards:
return ""
return f'<h3>추천 일정 <b>{len(cards)}</b></h3><div class="acc" data-more="3">{"".join(cards)}</div>'
def story_block(payload) -> str:
out = []
for sid, title in (("reading", "군산 읽기"), ("people", "인물 열전"),
("chronicle", "시간의 골목"), ("songs", "가요 다방")):
rows = section_items(payload, sid)
if not rows:
continue
items = "".join(
f'<details><summary>{esc(r.get("title") or r.get("name"))}</summary>'
f'<p>{esc((r.get("body") or r.get("summary") or r.get("connection") or "")[:400])}</p></details>'
for r in rows
)
out.append(f'<h3>{title} <b>{len(rows)}</b></h3><div class="acc" data-more="3">{items}</div>')
return "".join(out)
def close_block(payload) -> str:
place = payload["place"]
links = [l for l in payload["links"] if l.get("confirmed") and l.get("channel") in (1, 2, 3, 7)]
url = links[0]["url"] if links else ""
tel = place.get("phone") or ""
return f"""
<section class="close" id="book">
<p class="eyebrow">Reservation</p>
<h2>머무실 날을 알려 주세요</h2>
{f'<a class="book" href="{esc(url)}" target="_blank" rel="noopener noreferrer">네이버 예약으로 이동</a>' if url else ''}
{f'<a class="tel" href="tel:{esc(tel)}">전화 {esc(tel)}</a>' if tel else ''}
</section>
<footer>{esc(place.get("roadAddress") or place.get("address") or "")}</footer>"""
CSS = """
:root{
--paper:#fff; --ink:#141414; --soft:#6f6f6f; --line:#e6e4df;
--pad:22px; --wide:640px;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
html{scroll-behavior:smooth}
body{
background:var(--paper); color:var(--ink);
font:15.5px/1.9 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif;
-webkit-font-smoothing:antialiased; word-break:keep-all;
}
.app{max-width:var(--wide);margin:0 auto;overflow-x:hidden}
img{display:block;width:100%;height:100%;object-fit:cover}
a{color:inherit;text-decoration:none}
.eyebrow{font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--soft);
font-weight:500;margin:0 0 14px}
h2{font-size:23px;line-height:1.55;font-weight:400;letter-spacing:.06em;margin:0 0 18px}
h3{font-size:16px;font-weight:600;letter-spacing:.02em;margin:0 0 14px;
display:flex;align-items:baseline;gap:7px}
h3 span{font-size:12px;font-weight:400;color:var(--soft);letter-spacing:.1em}
p{margin:0 0 16px}
.lead{font-size:16.5px;line-height:2;color:#2a2a2a}
.sec{padding:76px var(--pad)}
.sec--tight{padding:44px var(--pad)}
.rule{height:1px;background:var(--line);margin:0 var(--pad)}
/* ── 히어로 — 사진이 천천히 교차하고 문구가 갈린다 ── */
.hero{position:relative;height:92svh;min-height:560px;background:#0e0e0e;overflow:hidden}
.stage{position:absolute;inset:0}
.stage img{position:absolute;inset:0;opacity:0;transform:scale(1.08);
transition:opacity 1.6s ease, transform 8s linear}
.stage img.on{opacity:.9;transform:scale(1)}
.film{position:absolute;inset:0;opacity:0;transition:opacity 1.2s ease;pointer-events:none}
.film.on{opacity:.92}
.film iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:100vw;height:calc(100vw * 16 / 9);min-height:100%;min-width:calc(100svh * 9 / 16);border:0}
.film-card .play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
width:48px;height:48px;border-radius:50%;background:#0000005c;backdrop-filter:blur(2px)}
.film-card .play::after{content:'';position:absolute;top:50%;left:54%;transform:translate(-50%,-50%);
border-left:13px solid #fff;border-top:8px solid transparent;border-bottom:8px solid transparent}
.films .shotcard{aspect-ratio:9/16}
.tube{position:relative;aspect-ratio:9/16;margin-bottom:18px;background:#000}
.tube iframe{position:absolute;inset:0;width:100%;height:100%;border:0}
.veil{position:absolute;inset:0;
background:linear-gradient(180deg,rgba(0,0,0,.4) 0%,rgba(0,0,0,.05) 42%,rgba(0,0,0,.62) 100%)}
.hero .copy{position:absolute;left:0;right:0;bottom:0;padding:0 var(--pad) 74px;color:#fff}
.hero .copy .eyebrow{color:#ffffffc4;animation:rise .9s .1s both}
.hero h1{font-size:31px;line-height:1.45;font-weight:300;letter-spacing:.11em;margin:0 0 14px;
animation:rise 1s .25s both}
.hero .say{font-size:15.5px;color:#fff;margin:0;letter-spacing:.04em;min-height:2.6em;
text-shadow:0 1px 14px rgba(0,0,0,.35)}
.hero .say.out{opacity:0;transform:translateY(6px)}
.hero .say{transition:opacity .5s ease, transform .5s ease}
@keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.down{position:absolute;left:50%;bottom:22px;transform:translateX(-50%);
width:44px;height:44px;display:grid;place-items:center}
.down i{width:1px;height:26px;background:#fff9;animation:drop 2.2s ease-in-out infinite}
@keyframes drop{0%,100%{transform:scaleY(.4);transform-origin:top;opacity:.4}
50%{transform:scaleY(1);opacity:1}}
.top{position:fixed;top:0;left:0;right:0;z-index:40;max-width:var(--wide);margin:0 auto;
display:flex;align-items:center;gap:10px;padding:0 var(--pad);height:56px;
background:#fffffff2;border-bottom:1px solid var(--line);
transform:translateY(-100%);transition:transform .25s ease}
.top.on{transform:none}
.top .name{font-size:15px;letter-spacing:.08em;flex:1}
.top .book{font-size:13px;letter-spacing:.06em;border:1px solid var(--ink);
padding:0 16px;min-height:44px;display:flex;align-items:center}
/* 스크롤에 맞춰 조용히 올라온다 */
.rise{opacity:0;transform:translateY(18px);transition:opacity .7s ease, transform .7s ease}
.rise.seen{opacity:1;transform:none}
.duo{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:0 6px;margin:0}
.duo li{list-style:none;aspect-ratio:3/4;overflow:hidden}
.duo li:first-child{grid-column:1 / -1;aspect-ratio:4/3}
/* ── 사진 카드 — 그라데이션 위에 카피 (모달을 연다) ── */
.seeall{margin-left:auto;border:0;background:none;font:inherit;font-size:13px;
color:var(--soft);letter-spacing:.04em;cursor:pointer;min-height:44px;padding:0 0 0 12px}
.chips{display:flex;gap:7px;overflow-x:auto;touch-action:pan-y pinch-zoom;
margin:0 0 16px;padding:2px 0 4px;scrollbar-width:none;list-style:none}
.chips::-webkit-scrollbar{display:none}
.chip{flex:none;min-height:44px;padding:0 16px;border:1px solid var(--line);border-radius:999px;
background:none;font:inherit;font-size:14px;color:var(--soft);cursor:pointer;white-space:nowrap}
.chip.on{background:var(--ink);border-color:var(--ink);color:#fff}
.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0 0 8px;padding:0;list-style:none}
.tile{position:relative;display:block;width:100%;aspect-ratio:1/1;overflow:hidden;
border:0;padding:0;background:#eee;cursor:pointer}
.tile b{position:absolute;left:8px;bottom:8px;color:#fff;font-size:13px;font-weight:500;
letter-spacing:.01em;text-align:left;line-height:1.35;padding-right:8px}
.shots2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0;padding:0;list-style:none}
.shotcard{position:relative;display:block;width:100%;aspect-ratio:3/4;overflow:hidden;
border:0;padding:0;background:#eee;cursor:pointer;text-align:left}
.shotcard img{transition:transform .7s ease}
.shotcard:active img{transform:scale(1.04)}
.shotcard .noimg{display:block;width:100%;height:100%;background:#efece6}
.shotcard .grad{position:absolute;inset:0;
background:linear-gradient(180deg,rgba(0,0,0,0) 38%,rgba(0,0,0,.72) 100%)}
.shotcard .cap{position:absolute;left:12px;right:12px;bottom:12px;color:#fff;display:block}
.shotcard .cap em{display:block;font-style:normal;font-size:11px;letter-spacing:.14em;
color:#ffffffc4;margin-bottom:3px}
.shotcard .cap b{display:block;font-size:15px;font-weight:500;line-height:1.45;letter-spacing:.02em}
/* ── 모달 ── */
.sheet{position:fixed;inset:0;z-index:60;display:none}
.sheet.on{display:block}
.sheet .bg{position:absolute;inset:0;background:#0009;opacity:0;transition:opacity .25s}
.sheet.on .bg{opacity:1}
.sheet .box{position:absolute;left:0;right:0;bottom:0;max-width:var(--wide);margin:0 auto;
background:#fff;max-height:88svh;overflow:auto;padding:26px var(--pad) 34px;
transform:translateY(100%);transition:transform .3s cubic-bezier(.2,.8,.2,1)}
.sheet.on .box{transform:none}
.sheet h4{font-size:20px;font-weight:400;letter-spacing:.05em;margin:0 0 6px}
.sheet .meta{font-size:12px;letter-spacing:.1em;color:var(--soft);margin:0 0 18px}
.sheet img{aspect-ratio:4/3;margin:0 0 18px}
.sheet p{color:#3a3a3a}
.sheet .out{display:flex;align-items:center;justify-content:center;min-height:52px;
border:1px solid var(--ink);font-size:14px;letter-spacing:.08em;margin-top:8px}
.sheet .x{position:absolute;top:-52px;right:var(--pad);width:44px;height:44px;border:0;
background:none;color:#fff;font-size:26px;line-height:1;cursor:pointer}
.room{margin:0 0 64px}
.room .shot{aspect-ratio:4/3;overflow:hidden;margin-bottom:22px}
.room .body{padding:0 var(--pad)}
.room h3{font-size:19px;font-weight:400;letter-spacing:.08em;margin:0 0 6px;display:block}
.room .sub{color:var(--soft);font-size:13.5px;letter-spacing:.04em;margin:0 0 14px}
.room .specs{display:flex;flex-wrap:wrap;gap:0 18px;margin:0 0 14px;padding:0;list-style:none}
.room .specs li{font-size:13.5px;color:var(--soft);position:relative;padding-left:11px}
.room .specs li::before{content:'';position:absolute;left:0;top:.9em;width:3px;height:3px;
border-radius:50%;background:var(--soft)}
.room p{color:#3a3a3a}
.amen{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin:0;padding:0;list-style:none}
.amen li{display:flex;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-size:14.5px}
.amen li b{font-weight:500}
.amen .off{color:#a8a8a8}
.amen .off b{text-decoration:line-through;text-decoration-thickness:1px}
.rows{margin:0;padding:0}
.rows>div{padding:14px 0;border-bottom:1px solid var(--line)}
.rows dt{font-size:12px;letter-spacing:.14em;color:var(--soft);margin:0 0 4px;text-transform:uppercase}
.rows dd{margin:0;font-size:15px}
.rows em{display:block;font-style:normal;color:var(--soft);font-size:13.5px;margin-top:3px}
/* FAQ — Q 표식 + 펼침 (레퍼런스 2) */
.acc details{border-bottom:1px solid var(--line)}
.acc summary{list-style:none;cursor:pointer;min-height:56px;display:flex;align-items:center;
gap:12px;font-size:14.5px;padding:8px 0}
.acc summary::-webkit-details-marker{display:none}
.acc summary::before{content:'Q';flex:none;width:22px;height:22px;border-radius:50%;
background:#f2efe9;color:var(--soft);font-size:11px;display:grid;place-items:center;
letter-spacing:0;margin-top:1px}
.acc summary::after{content:'';flex:none;margin-left:auto;width:8px;height:8px;
border-right:1px solid #9a9a9a;border-bottom:1px solid #9a9a9a;transform:rotate(45deg);
transition:transform .2s;margin-bottom:4px}
.acc details[open] summary::after{transform:rotate(-135deg);margin-bottom:0}
.acc summary em{font-style:normal;color:var(--soft);font-size:13px;margin-left:auto;padding-right:10px}
.acc p,.acc ol{margin:0;padding:0 0 18px 34px;color:var(--soft);font-size:14.5px;line-height:1.85}
.stops{padding-left:52px !important;list-style:decimal}
.more{width:100%;min-height:48px;margin-top:18px;border:1px solid var(--line);
background:none;font:inherit;font-size:13px;letter-spacing:.12em;color:var(--soft);cursor:pointer}
.close{padding:86px var(--pad) 96px;text-align:center;border-top:1px solid var(--line)}
.close h2{margin-bottom:26px}
.close .book{border:1px solid var(--ink);min-height:56px;
display:flex;align-items:center;justify-content:center;font-size:15px;letter-spacing:.1em}
.close .tel{display:block;margin-top:14px;color:var(--soft);font-size:14px;letter-spacing:.06em}
footer{padding:0 var(--pad) 40px;color:#a8a8a8;font-size:12px;text-align:center;letter-spacing:.06em}
@media (prefers-reduced-motion:reduce){
*{transition:none!important;animation:none!important}
.rise{opacity:1;transform:none}
}
"""
JS = """
(function(){
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 히어로 — 사진 교차 + 문구 순환
var stage=document.querySelectorAll('.stage img'), say=document.getElementById('say'),
raw=document.getElementById('lines');
var lines=[]; try{ lines=JSON.parse(raw.textContent)||[]; }catch(e){}
if(stage.length>1 && !reduce){
var si=0;
setInterval(function(){
stage[si].classList.remove('on');
si=(si+1)%stage.length;
stage[si].classList.add('on');
}, 6000);
}
if(say && lines.length>1){
var li=0;
setInterval(function(){
say.classList.add('out');
setTimeout(function(){
li=(li+1)%lines.length; say.textContent=lines[li];
say.classList.remove('out');
}, 480);
}, 5000);
}
// 히어로 영상 — 사진을 먼저 깔고, 준비되면 그 위를 덮는다.
// ★ 무음이어야 자동재생이 허용된다(소리는 사용자 조작 없이는 절대 안 난다).
// ★ 실패하거나 느리면 사진이 그대로 남는다 — 검은 화면을 만들지 않는다.
var filmBox=document.getElementById('film'), fraw=document.getElementById('films'), films=[];
try{ films=JSON.parse(fraw.textContent)||[]; }catch(e){}
var save=(navigator.connection&&navigator.connection.saveData)===true;
if(filmBox && films.length && !reduce && !save){
var f=document.createElement('iframe');
f.src='https://www.youtube.com/embed/'+films[0].id
+'?autoplay=1&mute=1&loop=1&playlist='+films[0].id
+'&controls=0&playsinline=1&modestbranding=1&rel=0&disablekb=1&fs=0';
f.title=''; f.tabIndex=-1; f.setAttribute('aria-hidden','true');
f.setAttribute('allow','autoplay; encrypted-media');
f.addEventListener('load', function(){ setTimeout(function(){ filmBox.classList.add('on'); }, 600); });
filmBox.appendChild(f);
}
// 상단 바
var bar=document.getElementById('topbar'), hero=document.querySelector('.hero');
if(bar&&hero&&'IntersectionObserver' in window){
new IntersectionObserver(function(e){ bar.classList.toggle('on', !e[0].isIntersecting); },
{rootMargin:'-70px 0px 0px 0px'}).observe(hero);
}
// 스크롤에 맞춰 올라오기
if('IntersectionObserver' in window){
var io=new IntersectionObserver(function(es){
es.forEach(function(e){ if(e.isIntersecting){ e.target.classList.add('seen'); io.unobserve(e.target); } });
},{rootMargin:'0px 0px -12% 0px'});
document.querySelectorAll('.sec, .room, .duo').forEach(function(el){
el.classList.add('rise'); io.observe(el);
});
}
// 필터 칩 — 레퍼런스 1(인기 추천 숙소)
document.querySelectorAll('.chip').forEach(function(chip){
chip.addEventListener('click', function(){
var kind=chip.dataset.filter, box=document.getElementById('nearby');
if(!box) return;
chip.parentNode.querySelectorAll('.chip').forEach(function(c){ c.classList.toggle('on', c===chip); });
[].slice.call(box.children).forEach(function(li){
li.dataset.hidden = (kind==='all' || li.dataset.kind===kind) ? '' : '1';
li.style.display = li.dataset.hidden ? 'none' : '';
});
var btn=box.parentNode.querySelector('.more'); if(btn) btn.remove();
});
});
// 섹션 헤더의 '더보기 ›' — 그 섹션의 목록을 전부 편다
document.querySelectorAll('[data-all]').forEach(function(btn){
btn.addEventListener('click', function(){
var sec=btn.closest('section') || document;
sec.querySelectorAll('[data-more]').forEach(function(box){
[].slice.call(box.children).forEach(function(el){ if(!el.dataset.hidden) el.style.display=''; });
});
sec.querySelectorAll('.more').forEach(function(m){ m.remove(); });
btn.remove();
});
});
// 더 보기
document.querySelectorAll('[data-more]').forEach(function(box){
var keep=parseInt(box.dataset.more,10), kids=[].slice.call(box.children);
if(kids.length<=keep) return;
kids.forEach(function(el,i){ if(i>=keep) el.style.display='none'; });
var btn=document.createElement('button');
btn.className='more'; btn.type='button';
btn.textContent='더 보기 ('+(kids.length-keep)+')';
btn.addEventListener('click',function(){
kids.forEach(function(el){ el.style.display=''; }); btn.remove();
});
box.parentNode.insertBefore(btn, box.nextSibling);
});
// 모달 — 새 페이지로 보내지 않는다
var sheet=document.createElement('div');
sheet.className='sheet';
sheet.innerHTML='<div class="bg"></div><div class="box"><button class="x" type="button" aria-label="닫기">×</button><div class="in"></div></div>';
document.body.appendChild(sheet);
var box=sheet.querySelector('.in'), scrollY=0;
function open(id){
var tpl=document.getElementById(id); if(!tpl) return;
box.innerHTML=tpl.innerHTML;
scrollY=window.scrollY;
sheet.classList.add('on');
document.body.style.position='fixed';
document.body.style.top=(-scrollY)+'px';
document.body.style.width='100%';
}
function close(){
sheet.classList.remove('on');
document.body.style.position=''; document.body.style.top=''; document.body.style.width='';
window.scrollTo(0, scrollY);
}
document.addEventListener('click', function(e){
var card=e.target.closest('[data-modal]');
if(card){ open(card.dataset.modal); return; }
if(e.target.closest('.sheet .bg') || e.target.closest('.sheet .x')) close();
});
document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
})();
"""
def build() -> str:
payload = json.loads(PAYLOAD.read_text(encoding="utf-8"))
facts = fact_map(payload["facts"])
place = payload["place"]
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>{esc(place["name"])}</title>
<meta name="robots" content="noindex">
<style>{CSS}</style>
</head>
<body>
<div class="app">
{hero(payload)}
{intro_block(payload)}
{gallery_block(payload)}
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">Film</p>
<h2>머뭄의 하루</h2>
{film_block(payload)}
</section>
<section class="sec">
<p class="eyebrow">Rooms</p>
<h2>두 채가 각각 한 팀만 씁니다</h2>
</section>
{units_block(payload)}
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">Information</p>
<h2>머물기 전에</h2>
{amenities(facts)}
{rules(facts)}
</section>
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">Around</p>
<h2>걸어서 닿는 곳</h2>
{spots_row(payload)}
{places_block(payload)}
{festival_block(payload)}
</section>
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">Gunsan</p>
<h2>이 동네를 걷는 법</h2>
{itinerary_block(payload)}
{story_block(payload)}
</section>
<div class="rule"></div>
<section class="sec sec--tight">
<p class="eyebrow">Q &amp; A</p>
{faq_block(payload)}
</section>
{close_block(payload)}
</div>
<script>{JS}</script>
</body>
</html>"""
if __name__ == "__main__":
OUT.parent.mkdir(parents=True, exist_ok=True)
doc = build()
OUT.write_text(doc, encoding="utf-8")
print(f"stay2.html {len(doc):,}자")