# 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
729 lines
32 KiB
Python
729 lines
32 KiB
Python
"""`/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&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 & 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):,}자")
|