o2o-site-AEO/solution/site/scripts/mockup/build_stay6.py
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

181 lines
7.5 KiB
Python

"""/s/stay 를 세 페이지로 나눈 판을 /s/stay6 으로 굽는다.
stay3·stay4 는 HTML5 UP 템플릿으로 새로 만든 것이라 렌더러와 무관했다. stay6 은 반대다 —
**/s/stay 와 같은 리액트 렌더러·같은 payload** 를 쓰고, `theme.sections[].enabled` 만
페이지별로 갈라 SSR 을 세 번 돌린다. 그래서 캐러셀·미니 플레이어·엽서 캔버스·후기 모달이
전부 /s/stay 와 같은 것으로 선다.
★ 렌더러(solution/site/src)는 건드리지 않는다. 페이지마다 다른 것은 payload 의 enabled 와
주입하는 css·js 뿐이다.
★ 후기(ReviewSection)·엽서(PostcardMakerSection)는 HomePage 가 섹션 목록과 무관하게
항상 그린다. 페이지를 나누려면 렌더러를 고쳐야 하는데 그건 /s/stay 를 같이 바꾸는 일이라,
여기서는 주입 css 로 감춘다 — DOM 에는 남는다.
★ 히어로도 항상 나온다. 페이지 머리로 쓴다.
입력: build/index.html (patch_stay.py 결과 — 껍데기·head·주입분) · stay-payload-new.json
출력: build6/{index.html, gunsan/index.html, booking/index.html}
"""
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
SP = Path(__file__).parent
SITE = SP.parents[1]
OUT = SP / "build6"
SSR = SITE / "dist" / "ssr6" / "ssr6.js"
SHELL = SP / "build" / "index.html"
PAYLOAD = SP / "build" / "index.html"
BASE = "/s/stay6"
PAGES = [
("", "소개", {"hero", "intro", "rooms", "video", "photos", "map", "weather"},
{"reviews"}),
# ★ map 을 끄면 HomePage 가 '오시는 길' 을 기본으로 한 번 더 낸다(HomePage.tsx:118).
# 군산 페이지에는 필요 없어서 주입 css 로 감춘다.
("gunsan", "군산", {"hero", "festival", "local", "itinerary", "story",
"songs", "people", "chronicle", "reading"},
{"reviews", "postcard-maker", "location"}),
("booking", "예약·후기", {"hero", "info", "booking", "faq", "rules", "map"},
{"postcard-maker"}),
]
# 화면 앵커 id → 그 섹션이 사는 페이지.
# ★ 페이지를 나누면 헤더 nav 의 `#festival` 같은 앵커가 **그 페이지에 없는 자리**를 가리킨다.
# 바를 하나 더 얹지 않고, 사이트가 이미 가진 nav 의 href 만 페이지 주소로 고친다.
ANCHOR_PAGE = {
"top": "", "about": "", "units": "", "video": "", "gallery": "",
"location": "", "weather": "", "postcard-maker": "",
"festival": "gunsan", "guide": "gunsan", "itinerary": "gunsan",
"story": "gunsan", "songs": "gunsan",
"info": "booking", "booking": "booking", "faq": "booking", "reviews": "booking",
}
NAV_CSS = """
[data-w4d-here] { font-weight: 800; text-decoration: underline; text-underline-offset: 5px; }
"""
# ★ HTML 을 고쳐도 소용없다 — 리액트가 하이드레이션하면서 헤더를 다시 그려 덮는다(실측).
# 그래서 런타임에 고친다. 다시 그려도 되도록 클릭을 가로채는 쪽이 본체이고,
# href 를 바꾸는 것은 마우스 올렸을 때 주소가 맞게 보이라고 덤으로 한다.
NAV_JS = """
<script>
(function () {
var BASE = %(base)s, HERE = %(here)s, PAGE = %(page)s;
function target(id) {
var page = PAGE[id];
if (page === undefined || page === HERE) return null;
return page ? BASE + '/' + page + '#' + id : BASE + '#' + id;
}
function fix() {
var anchors = document.querySelectorAll('a[href^="#"]');
for (var i = 0; i < anchors.length; i++) {
var id = anchors[i].getAttribute('href').slice(1);
var to = target(id);
if (to) anchors[i].setAttribute('href', to);
else if (PAGE[id] === HERE) anchors[i].setAttribute('data-w4d-here', '');
}
}
document.addEventListener('click', function (event) {
var a = event.target.closest && event.target.closest('a[href^="#"]');
if (!a) return;
var to = target(a.getAttribute('href').slice(1));
if (!to) return;
event.preventDefault();
location.href = to;
}, true);
[0, 400, 1200, 3000].forEach(function (ms) { setTimeout(fix, ms); });
window.addEventListener('pageshow', fix);
})();
</script>
"""
HIDE_CSS = {
"reviews": "#reviews { display: none !important; }",
"postcard-maker": "#postcard-maker { display: none !important; }",
"location": "#location { display: none !important; }",
}
def shell_parts():
html = SHELL.read_text(encoding="utf-8")
open_tag = '<div id="root">'
start = html.index(open_tag) + len(open_tag)
end = html.index('<script>window.__SITE_PAYLOAD__')
close = html.rindex("</div>", start, end)
payload_end = html.index("</script>", end) + len("</script>")
return html[:start], html[close:end], html[payload_end:], html
def baked_payload():
"""굽힌 /s/stay 에서 payload 를 그대로 떠 온다 — stay-payload-new.json 은 patch_stay 중간
스냅샷이라 booking 켜기·탭 비우기·사진 주소 교체가 아직 안 들어가 있다."""
html = PAYLOAD.read_text(encoding="utf-8")
start = html.index("window.__SITE_PAYLOAD__=") + len("window.__SITE_PAYLOAD__=")
end = html.index("</script>", start)
return json.loads(html[start:end].rstrip().rstrip(";"))
def variant(payload, keep):
"""stay 가 켜 둔 섹션 중 이 페이지 몫만 남긴다. 꺼져 있던 것을 켜지 않는다 —
'군산 이야기' 안의 탭(가요·인물·시간의 골목·읽기)이 따로 서 버린다."""
out = json.loads(json.dumps(payload))
for section in out["theme"]["sections"]:
section["enabled"] = bool(section.get("enabled")) and section["id"] in keep
return out
def nav_js(current):
return NAV_JS % {
"base": json.dumps(BASE),
"here": json.dumps(current),
"page": json.dumps(ANCHOR_PAGE, ensure_ascii=False),
}
def main():
if not SSR.exists():
sys.exit(f"SSR 번들이 없다: {SSR}\n cd {SITE} && npx vite build --ssr scripts/mockup/ssr6.ts --outDir dist/ssr6")
if not SHELL.exists():
sys.exit(f"껍데기가 없다: {SHELL} — 먼저 patch_stay.py 를 돌린다")
base_payload = baked_payload()
head, tail_root, tail, whole = shell_parts()
if OUT.exists():
shutil.rmtree(OUT)
OUT.mkdir(parents=True)
for slug, _label, keep, hide in PAGES:
page_payload = variant(base_payload, keep)
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "p.json"
dst = Path(tmp) / "p.html"
src.write_text(json.dumps(page_payload, ensure_ascii=False), encoding="utf-8")
subprocess.run(["node", str(SSR), str(src), str(dst)], check=True)
app = dst.read_text(encoding="utf-8")
payload_js = f"<script>window.__SITE_PAYLOAD__={json.dumps(page_payload, ensure_ascii=False)}</script>"
html = head + app + tail_root + payload_js + tail
html = html.replace("</body>", nav_js(slug) + "</body>", 1)
extra = NAV_CSS + "".join(HIDE_CSS[key] for key in sorted(hide))
html = html.replace("</head>", f"<style>{extra}</style></head>", 1)
html = html.replace("/s/stay/", f"{BASE}/")
html = re.sub(r'(<link rel="canonical" href="[^"]*?)/s/stay"',
rf'\1{BASE}/{slug}"' if slug else rf'\1{BASE}"', html)
target = (OUT / slug / "index.html") if slug else (OUT / "index.html")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(html, encoding="utf-8")
print(f"{target.relative_to(OUT)} {target.stat().st_size:,}자 섹션 {len(keep)}")
if __name__ == "__main__":
main()