인앱 미니 블로그(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에서 보정).
181 lines
7.5 KiB
Python
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()
|