Merge branch 'feature/site-features-and-mockup' into main

This commit is contained in:
Mina Choi 2026-09-23 13:19:19 +09:00
commit 79962b93e2
98 changed files with 12916 additions and 149 deletions

8
.gitignore vendored
View File

@ -58,3 +58,11 @@ dist/
# 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .claude/settings.local.json 에 둔다. # 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .claude/settings.local.json 에 둔다.
.claude/settings.local.json .claude/settings.local.json
# 목업 작업 산출물 — 발행본 원본은 도커 볼륨(out/s)이라 레포에 두지 않는다
solution/site/scripts/mockup/backup/
solution/site/scripts/mockup/king-stay2/
solution/site/scripts/mockup/build6p/
solution/site/scripts/mockup/build6p-stay2/
solution/site/scripts/mockup/siann6/
solution/site/scripts/mockup/_sub*.mjs

View File

@ -412,7 +412,7 @@ async def coverage(place, place_id: str) -> dict:
} }
async def fetch_one(link): async def fetch_one(link, category=None):
"""링크 하나를 긁는다. 실패해도 예외를 던지지 않는다 — 나머지 링크가 살아야 한다.""" """링크 하나를 긁는다. 실패해도 예외를 던지지 않는다 — 나머지 링크가 살아야 한다."""
try: try:
adapter = REGISTRY.get_adapter(link.url) adapter = REGISTRY.get_adapter(link.url)
@ -421,7 +421,7 @@ async def fetch_one(link):
LOG.w(f"[collect] 어댑터 없음 — 건너뜀 {link.url}: {type(ex).__name__}") LOG.w(f"[collect] 어댑터 없음 — 건너뜀 {link.url}: {type(ex).__name__}")
return None, "no_adapter" return None, "no_adapter"
try: try:
source = await adapter.fetch(link.url) source = await adapter.fetch(link.url, category)
except Exception as ex: except Exception as ex:
collect_diagnostics.note_issue("fetch", link.url, ex) collect_diagnostics.note_issue("fetch", link.url, ex)
return None, "failed" return None, "failed"
@ -638,7 +638,7 @@ async def _run_collect(job: dict) -> dict:
f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략") f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략")
break break
source, outcome = await fetch_one(link) source, outcome = await fetch_one(link, PlaceCategory(place.category))
fetch_stat[outcome] += 1 fetch_stat[outcome] += 1
if source is None: if source is None:
continue continue

View File

@ -11,7 +11,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Protocol, runtime_checkable from typing import Optional, Protocol, runtime_checkable
from common.enums import LinkChannel from common.enums import LinkChannel, PlaceCategory
# ---- 도메인 예외 ----------------------------------------------------------- # ---- 도메인 예외 -----------------------------------------------------------
@ -154,4 +154,4 @@ class SourceAdapter(Protocol):
def can_handle(self, url: str) -> bool: ... def can_handle(self, url: str) -> bool: ...
async def fetch(self, url: str) -> RawSource: ... async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: ...

View File

@ -14,6 +14,7 @@ URL 규약 (업종을 URL 에서 읽어 결정적으로 동작한다)
mock://cafe/cafe-1?channel=naver_place mock://cafe/cafe-1?channel=naver_place
https://mock.test/restaurant/r-1 https://mock.test/restaurant/r-1
""" """
from typing import Optional
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from common.category_schema import get_schema from common.category_schema import get_schema
@ -227,7 +228,7 @@ class MockAdapter:
return True return True
return parsed.scheme in ("http", "https") and parsed.hostname in _HOSTS return parsed.scheme in ("http", "https") and parsed.hostname in _HOSTS
async def fetch(self, url: str) -> RawSource: async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
"""URL 에서 업종을 읽어 그 업종의 목데이터를 돌려준다. """URL 에서 업종을 읽어 그 업종의 목데이터를 돌려준다.
업종을 읽으면 예외가 아니라 실패 결과(ok=False) 돌려준다 업종을 읽으면 예외가 아니라 실패 결과(ok=False) 돌려준다

View File

@ -21,7 +21,7 @@ from typing import Optional
import httpx import httpx
from common.enums import LinkChannel from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG from common.logger import LOG
from services.collector.base import CollectedFact, CollectedMedia, RawSource from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -102,6 +102,18 @@ _WEEKEND_TOKENS = ("주말", "금토", "토일", "공휴일")
# 요일 토큰과, 바로 뒤에 붙는 괄호 보충설명("주말(금,토)")까지 한 번에 걷어낸다. # 요일 토큰과, 바로 뒤에 붙는 괄호 보충설명("주말(금,토)")까지 한 번에 걷어낸다.
_DAY_TAG = re.compile(rf"({'|'.join(_WEEKDAY_TOKENS + _WEEKEND_TOKENS)})\s*(\([^)]*\))?\s*") _DAY_TAG = re.compile(rf"({'|'.join(_WEEKDAY_TOKENS + _WEEKEND_TOKENS)})\s*(\([^)]*\))?\s*")
_UNIT_NAME_KEY = {
PlaceCategory.LODGING: "room_type",
PlaceCategory.CAFE: "menu_name",
PlaceCategory.RESTAURANT: "menu_name",
PlaceCategory.CLINIC: "program_name",
}
_UNIT_PRICE_KEY = {
PlaceCategory.CAFE: "menu_price",
PlaceCategory.RESTAURANT: "menu_price",
PlaceCategory.CLINIC: "price_adult",
}
class NaverPlaceAdapter: class NaverPlaceAdapter:
"""네이버 플레이스 상세 → fact·사진 후보.""" """네이버 플레이스 상세 → fact·사진 후보."""
@ -125,7 +137,7 @@ class NaverPlaceAdapter:
u = (url or "").lower() u = (url or "").lower()
return any(h in u for h in _HOSTS) return any(h in u for h in _HOSTS)
async def fetch(self, url: str) -> RawSource: async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
channel = LinkChannel.NAVER_PLACE channel = LinkChannel.NAVER_PLACE
try: try:
place_id = await self._resolve_place_id(url) place_id = await self._resolve_place_id(url)
@ -143,7 +155,7 @@ class NaverPlaceAdapter:
if not base: if not base:
return RawSource.failure(url, self.id, "응답에 PlaceDetailBase 가 없다", channel) return RawSource.failure(url, self.id, "응답에 PlaceDetailBase 가 없다", channel)
facts = self._to_facts(base, state) facts = self._to_facts(base, state, category)
media = self._to_media(state) media = self._to_media(state)
booking_url = self._booking_url(state) booking_url = self._booking_url(state)
@ -293,7 +305,7 @@ class NaverPlaceAdapter:
raise RuntimeError(f"네트워크 오류: {ex}") raise RuntimeError(f"네트워크 오류: {ex}")
raise RuntimeError(last) raise RuntimeError(last)
def _to_facts(self, base: dict, state: dict) -> list[CollectedFact]: def _to_facts(self, base: dict, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]:
"""★ 스키마에 있는 key 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다.""" """★ 스키마에 있는 key 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다."""
facts: list[CollectedFact] = [] facts: list[CollectedFact] = []
@ -345,10 +357,10 @@ class NaverPlaceAdapter:
seen.add(key) seen.add(key)
facts.append(CollectedFact(key=key, value="false" if negated else "true")) facts.append(CollectedFact(key=key, value="false" if negated else "true"))
facts.extend(self._to_unit_facts(state)) facts.extend(self._to_unit_facts(state, category))
return facts return facts
def _to_unit_facts(self, state: dict) -> list[CollectedFact]: def _to_unit_facts(self, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]:
"""요금표(`Menu:*`) → 단위(객실·메뉴·프로그램) 스코프 fact. """요금표(`Menu:*`) → 단위(객실·메뉴·프로그램) 스코프 fact.
필요한가 필요한가
@ -368,6 +380,9 @@ class NaverPlaceAdapter:
rows = [v for k, v in state.items() if k.startswith("Menu") and isinstance(v, dict)] rows = [v for k, v in state.items() if k.startswith("Menu") and isinstance(v, dict)]
rows.sort(key=lambda v: int(v.get("index") or 0)) rows.sort(key=lambda v: int(v.get("index") or 0))
name_key = _UNIT_NAME_KEY.get(category, "room_type")
flat_price_key = _UNIT_PRICE_KEY.get(category) if category is not None else None
out: list[CollectedFact] = [] out: list[CollectedFact] = []
seen_names: list[str] = [] seen_names: list[str] = []
for row in rows: for row in rows:
@ -382,16 +397,17 @@ class NaverPlaceAdapter:
if unit_name not in seen_names: if unit_name not in seen_names:
seen_names.append(unit_name) seen_names.append(unit_name)
# room_type 은 숙박 스키마의 unit 필수 필드다. 이름 자체가 상품 구분이므로 그대로 싣는다.
out.append(CollectedFact( out.append(CollectedFact(
key="room_type", value=unit_name, scope="unit", unit_name=unit_name, key=name_key, value=unit_name, scope="unit", unit_name=unit_name,
)) ))
# 요금. 네이버는 문자열 숫자("20000")로 준다 — 표기는 렌더 단계(format_value)가 만든다. # 요금. 네이버는 문자열 숫자("20000")로 준다 — 표기는 렌더 단계(format_value)가 만든다.
price = str(row.get("price") or "").strip().replace(",", "") price = str(row.get("price") or "").strip().replace(",", "")
if not price.isdigit(): if not price.isdigit():
continue continue
if any(t in raw_name for t in _WEEKEND_TOKENS): if flat_price_key:
price_key = flat_price_key
elif any(t in raw_name for t in _WEEKEND_TOKENS):
price_key = "weekend_price" price_key = "weekend_price"
elif any(t in raw_name for t in _WEEKDAY_TOKENS): elif any(t in raw_name for t in _WEEKDAY_TOKENS):
price_key = "weekday_price" price_key = "weekday_price"

View File

@ -47,7 +47,7 @@ from urllib.robotparser import RobotFileParser
import httpx import httpx
from common.enums import LinkChannel from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG from common.logger import LOG
from services.collector.base import CollectedFact, CollectedMedia, RawSource from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -200,7 +200,7 @@ class StaticHtmlAdapter:
return False return False
return not any(host == d or host.endswith("." + d) for d in _DENY_HOSTS) return not any(host == d or host.endswith("." + d) for d in _DENY_HOSTS)
async def fetch(self, url: str) -> RawSource: async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
channel = self._channel(url) channel = self._channel(url)
allowed, why = await self._robots_allows(url) allowed, why = await self._robots_allows(url)

View File

@ -33,7 +33,7 @@ from urllib.parse import unquote, urlencode
import httpx import httpx
from common.enums import LinkChannel from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG from common.logger import LOG
from config.server_configs import external_api_config from config.server_configs import external_api_config
from services.collector.base import CollectedFact, CollectedMedia, RawSource from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -104,7 +104,7 @@ class TourApiAdapter:
return True return True
return any(h in u for h in _HOSTS) and bool(_COTID.search(u)) return any(h in u for h in _HOSTS) and bool(_COTID.search(u))
async def fetch(self, url: str) -> RawSource: async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
channel = LinkChannel.ETC channel = LinkChannel.ETC
key = (external_api_config.tour_api_key or "").strip() key = (external_api_config.tour_api_key or "").strip()
if not key: if not key:

View File

@ -23,7 +23,7 @@ from typing import Optional
from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright
from common.enums import LinkChannel from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG from common.logger import LOG
from services.collector.base import CollectedFact, CollectedMedia, RawSource from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -249,7 +249,7 @@ class YanoljaAdapter:
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
return bool(DETAIL_URL_RE.search((url or "").lower())) return bool(DETAIL_URL_RE.search((url or "").lower()))
async def fetch(self, url: str) -> RawSource: async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
pw, browser, page = await _new_page() pw, browser, page = await _new_page()
try: try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000) await page.goto(url, wait_until="domcontentloaded", timeout=60000)

View File

@ -48,6 +48,16 @@ const LOOK = {
texture: texture:
'repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)', 'repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)',
}, },
paper: {
fontHeading: "'Noto Serif KR', 'AppleMyungjo', 'Nanum Myeongjo', serif",
fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif",
radius: '0px',
borderWidth: '1px',
shadow: 'none',
headingTracking: '0.03em',
headingWeight: '400',
sectionSpace: '4.5rem',
},
} as const; } as const;
/** 업종 하나의 템플릿 세 벌. accent 만 업종이 정한다. */ /** 업종 하나의 템플릿 세 벌. accent 만 업종이 정한다. */
@ -151,6 +161,27 @@ function templatesFor(
return retro.isDefault ? [items[2], items[0], items[1]] : items; return retro.isDefault ? [items[2], items[0], items[1]] : items;
} }
function paperTemplate(industryId: IndustryType, description: string): TemplateItem {
return {
id: `${industryId}-paper`,
industryId,
name: '고택',
tone: 'book',
toneLabel: '고택 지면',
description,
colors: {
primary: '#1f1d19',
secondary: '#726c61',
bg: '#fdfcfa',
card: '#f2efe8',
text: '#1f1d19',
accent: '#1f1d19',
},
fontStyle: '정갈한 명조',
look: LOOK.paper,
};
}
export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = { export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
stay: { stay: {
@ -183,14 +214,17 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },
{ id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' }, { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' },
], ],
templates: templatesFor('stay', '#2563eb', { templates: [
name: '옛 항구', ...templatesFor('stay', '#2563eb', {
description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.', name: '옛 항구',
// 숙박의 기본 템플릿 (2026-09-10, 사장님 지시). 백엔드 `_DEFAULT_THEME` 도 stay-retro 다. description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.',
isDefault: true, // 숙박의 기본 템플릿 (2026-09-10, 사장님 지시). 백엔드 `_DEFAULT_THEME` 도 stay-retro 다.
/** 시안의 사진 갤러리는 캐러셀이다. 색·서체만 맞고 모양이 기본이면 시안이 안 된다. */ isDefault: true,
defaultVariants: {photos: 'photos.carousel'}, /** 시안의 사진 갤러리는 캐러셀이다. 색·서체만 맞고 모양이 기본이면 시안이 안 된다. */
}), defaultVariants: {photos: 'photos.carousel'},
}),
paperTemplate('stay', '크림빛 종이에 가는 명조. 그림자도 장식도 없이, 백 년 된 집의 정갈함을 그대로 보여줍니다.'),
],
}, },
cafe: { cafe: {
@ -217,10 +251,13 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '반려견 동반, 주차, 케어키즈존 안내' },
{ id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' }, { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' },
], ],
templates: templatesFor('cafe', '#b45309', { templates: [
name: '옛 다방', ...templatesFor('cafe', '#b45309', {
description: '갱지 바탕에 간판체. LP 와 손글씨로, 다방 시절의 인상으로 지금의 커피를 이야기합니다.', name: '옛 다방',
}), description: '갱지 바탕에 간판체. LP 와 손글씨로, 다방 시절의 인상으로 지금의 커피를 이야기합니다.',
}),
paperTemplate('cafe', '크림빛 종이에 가는 명조. 그림자도 장식도 없이, 조용한 카페의 정갈함을 그대로 보여줍니다.'),
],
}, },
restaurant: { restaurant: {
@ -247,10 +284,13 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '콜키지 정책, 알러지 케어, 주차 안내' },
{ id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' }, { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' },
], ],
templates: templatesFor('restaurant', '#16a34a', { templates: [
name: '노포', ...templatesFor('restaurant', '#16a34a', {
description: '갱지 바탕에 간판체. 오래 해온 집이라는 사실 자체가 메뉴판이 됩니다.', name: '노포',
}), description: '갱지 바탕에 간판체. 오래 해온 집이라는 사실 자체가 메뉴판이 됩니다.',
}),
paperTemplate('restaurant', '크림빛 종이에 가는 명조. 고택에서 차리는 한 상처럼, 담백하고 단정한 인상을 남깁니다.'),
],
}, },
clinic: { clinic: {

View File

@ -207,6 +207,7 @@ export function Step2PlaceSearch() {
} }
clearIdentity(); clearIdentity();
search.reset(); search.reset();
goToStep('search', {params: {placeId: null, flow: null}, replace: true});
// eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다 // eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다
}, []); }, []);

View File

@ -103,7 +103,7 @@ export function BuilderPage() {
* (/builder?placeId=...) . * (/builder?placeId=...) .
*/ */
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null); const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
const placeId = urlPlaceId ?? wizardPlaceId; const placeId = step === 'search' ? null : (urlPlaceId ?? wizardPlaceId);
const sync = usePlaceSync(placeId, {isOnboarding}); const sync = usePlaceSync(placeId, {isOnboarding});
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);

View File

@ -1,9 +1,10 @@
# 자동재생 — 안 된다. 왜 안 되는지와, 그래서 뭘 했는지 # 자동재생 — 안 된다. 왜 안 되는지와, 그래서 뭘 했는지
> ★ **2026-09-15 — 자동재생을 껐다.** 대표: "노래가 자동재생 안 되고 클릭하고 나서 되니까 > ★ **2026-09-22 — 자동재생은 켠다. 끄지 않는다.** 대표 지시("내가 키라고 몇번을 말해").
> 사람들이 에러로 보잖아 그냥 꺼놔." 아래 내용은 **왜 자동재생이 어려운가**의 기록으로 남긴다 > `inject.js:600``autoplay();` 다. `void autoplay;` 로 되돌리는 변경은 하지 않는다.
> — 되살릴 일이 생기면 `inject.js``void autoplay;``autoplay();` 로 되돌리면 된다. > (09-15 에 "에러로 보이니 꺼놔" 로 한 번 껐던 것을 되돌린 것이다 — 09-11 빼 → 09-14 켜 →
> 함수와 첫 손길 처리는 지우지 않았다. > 09-15 끔 → 09-22 켜.) 아래는 **왜 첫 방문에 소리가 안 나는가**의 기록이지,
> 호출을 빼라는 근거가 아니다 — 브라우저가 거절해도 호출은 건다.
`/s/stay` 배경음악을 **새로고침 직후 바로** 나게 하려고 시도한 것 전부와 실측값이다. `/s/stay` 배경음악을 **새로고침 직후 바로** 나게 하려고 시도한 것 전부와 실측값이다.
같은 얘기가 다시 나오면 이 파일부터 본다. 같은 얘기가 다시 나오면 이 파일부터 본다.

View File

@ -737,7 +737,7 @@ python3 patch_stay.py # vendor 안의 한 벌을 찾아 주소만 박는
|---|---|---| |---|---|---|
| 오늘의 한 장 | 탭을 세우지 않는다(사장님: "주석처리해주쇼") — `patch_stay.py` `HIDE_TABS` | `오늘의 한 장 숨김` — 탭이 **없어야** 통과 | | 오늘의 한 장 | 탭을 세우지 않는다(사장님: "주석처리해주쇼") — `patch_stay.py` `HIDE_TABS` | `오늘의 한 장 숨김` — 탭이 **없어야** 통과 |
| 엽서 | 도시 엽서 4장을 빼고 손님이 쓰는 엽서로(`inject.js` `postcardMaker`) | `엽서 쓰기``#w4d-pm-section` 유무 | | 엽서 | 도시 엽서 4장을 빼고 손님이 쓰는 엽서로(`inject.js` `postcardMaker`) | `엽서 쓰기``#w4d-pm-section` 유무 |
| 군산 읽기 | 34가지 중 **무작위 5~6꼭지** 카로셀. 신문 조판(제호·면·격자)은 걷어냈다 | `군산 읽기 화면` — 5~6꼭지 | | 군산 읽기 | **문학 8 · 인물 23 = 31꼭지 전부** 카로셀(2026-09-18 대표: "인물·문학만 담아 랜덤 대신 전체 노출하고 약 30개로 추린다"). 섬과 바다·역사·장소·음식은 뺐다 — `build_reading.py` `KEEP` | `군산 읽기 화면` — 31꼭지 전체 |
### 내용을 바꾼 뒤 (전체 굽기) ### 내용을 바꾼 뒤 (전체 굽기)
@ -935,10 +935,11 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
1. **캐치프레이즈 순환** — 대표 문구 `<p>` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1 1. **캐치프레이즈 순환** — 대표 문구 `<p>` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1
2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡. 2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡.
**자동재생은 껐다** (2026-09-15 대표: "자동재생 안 되고 클릭하고 나서 되니까 사람들이 **자동재생한다 — 끄지 않는다** (2026-09-22 대표 지시, "내가 키라고 몇번을 말해").
에러로 보잖아 그냥 꺼놔"). 세 번째 뒤집기다(09-11 빼 → 09-14 켜 → 09-15 끔). 이번 이유는 `inject.js:600``autoplay();` 가 정답이고 `void autoplay;` 로 되돌리지 않는다.
앞의 둘과 다르다 — 정책을 못 이겨서가 아니라, **정책에 걸린 그 모습이 고장으로 읽혀서**다. 09-11 빼 → 09-14 켜 → 09-15 끔 → 09-22 켜, 네 번 뒤집혔다. 뒤집힌 원인은 이 README 와
`inject.js``autoplay()` 는 남아 있고 호출만 뺐다(`void autoplay;`) → T4 `AUTOPLAY.md` 가 서로 반대로 적혀 있어서다(238줄 "한다" ↔ 여기 "껐다").
브라우저 정책상 첫 손길 전에는 어차피 거절되지만(`AUTOPLAY.md`), **호출은 건다** → T4
3. **군산 읽기** — ★ **2026-09-15 부터 렌더러에 있다**(`ReadingSection.tsx`, **T7**). 아래는 3. **군산 읽기** — ★ **2026-09-15 부터 렌더러에 있다**(`ReadingSection.tsx`, **T7**). 아래는
시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다. 시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다.
'군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가 '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가

View File

@ -157,7 +157,11 @@ ok('카세트 아이콘', d.cassette, d.cassette ? '있음' : '없음');
ok('자작곡 5곡', d.ownSongs === 5, `${d.ownSongs}`); ok('자작곡 5곡', d.ownSongs === 5, `${d.ownSongs}`);
ok('빨간 줄 없음', d.redRule === 0, `${d.redRule}`); ok('빨간 줄 없음', d.redRule === 0, `${d.redRule}`);
ok('바비큐 항목 유지', d.bbqRow, d.bbqRow ? '있음' : '사라짐'); ok('바비큐 항목 유지', d.bbqRow, d.bbqRow ? '있음' : '사라짐');
ok('맨 아래 가로선 제거', d.divider === '0px', d.divider); // ★ 'none' = 그 블록이 **아예 없다**. 2026-09-18 재굽기로 /s/stay 가 새 렌더러 번들
// (index-D4tfakmK.js)로 옮겨 가면서 '예약은 아래로 받습니다' 블록 자체가 사라졌다 —
// 선을 지우라던 지시(2026-09-10)는 그 블록이 있을 때의 것이고, 지금은 지울 선이 없다.
// 요소가 없는 것을 실패로 세면 점검이 '고쳐야 할 것' 을 가리키지 않는다.
ok('맨 아래 가로선 제거', d.divider === '0px' || d.divider === 'none', d.divider);
ok('히어로 원본 사진', /\/img\/mirror\//.test(d.heroImg || ''), d.heroImg); ok('히어로 원본 사진', /\/img\/mirror\//.test(d.heroImg || ''), d.heroImg);
ok('객실 사진 A12/B10', d.roomA === 12 && d.roomB === 10, `A ${d.roomA} · B ${d.roomB}`); ok('객실 사진 A12/B10', d.roomA === 12 && d.roomB === 10, `A ${d.roomA} · B ${d.roomB}`);
ok('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음'); ok('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
"""`/s/stay2` — 같은 내용, **펜션 공식 사이트** 레이아웃. """`/s/stay5` — 같은 내용, **펜션 공식 사이트** 레이아웃.
판은 Airbnb OTA 상세였다(2026-09-16). 그건 숙소를 **비교하러 ** 사람의 화면이고, 판은 Airbnb OTA 상세였다(2026-09-16). 그건 숙소를 **비교하러 ** 사람의 화면이고,
공식 사이트는 이미 집을 보러 사람의 화면이라 장르가 다르다 그래서 다시 만들었다. 공식 사이트는 이미 집을 보러 사람의 화면이라 장르가 다르다 그래서 다시 만들었다.
@ -11,7 +11,7 @@
· 야놀자·여기어때·스테이폴리오를 베끼지 않는다 민사 10 선례가 non-goal 이다(PRODUCT.md 6). · 야놀자·여기어때·스테이폴리오를 베끼지 않는다 민사 10 선례가 non-goal 이다(PRODUCT.md 6).
심미와 효율을 위아래로 나눈다: 위는 사진·여백(브랜드), 아래는 접힌 정보(밀도). 심미와 효율을 위아래로 나눈다: 위는 사진·여백(브랜드), 아래는 접힌 정보(밀도).
`/s/stay` 건드리지 않는다. 스크립트는 stay2 만든다. `/s/stay` 건드리지 않는다. 스크립트는 stay5 만든다.
""" """
import html as H import html as H
import json import json
@ -19,7 +19,7 @@ import re
from pathlib import Path from pathlib import Path
SP = Path(__file__).parent SP = Path(__file__).parent
OUT = SP / "build" / "stay2.html" OUT = SP / "build" / "stay5.html"
PAYLOAD = SP / "stay-payload-new.json" PAYLOAD = SP / "stay-payload-new.json"
@ -28,8 +28,8 @@ def esc(value) -> str:
def img_url(url: str) -> str: def img_url(url: str) -> str:
"""미러 사진은 stay2 자기 디렉토리에서 준다 — stay 를 지워도 안 깨진다.""" """미러 사진은 stay5 자기 디렉토리에서 준다 — stay 를 지워도 안 깨진다."""
return re.sub(r"^/assets/mirror/", "/s/stay2/img/mirror/", url or "") return re.sub(r"^/assets/mirror/", "/s/stay5/img/mirror/", url or "")
def fact_map(facts) -> dict: def fact_map(facts) -> dict:
@ -725,4 +725,4 @@ if __name__ == "__main__":
OUT.parent.mkdir(parents=True, exist_ok=True) OUT.parent.mkdir(parents=True, exist_ok=True)
doc = build() doc = build()
OUT.write_text(doc, encoding="utf-8") OUT.write_text(doc, encoding="utf-8")
print(f"stay2.html {len(doc):,}") print(f"stay5.html {len(doc):,}")

File diff suppressed because it is too large Load Diff

View File

@ -67,8 +67,9 @@ def naver(title):
# 갈래 — 화면에 이 순서로 선다. # 갈래 — 화면에 이 순서로 선다.
GROUPS = [ GROUPS = [
("문학", "이 도시를 쓴 사람과 그 자리"), ("문학", "이 도시를 쓴 사람과 그 자리"),
("인물", "이 도시가 낳은 사람들"),
] ]
KEEP = {"문학"} KEEP = {"문학", "인물"}
# (갈래, 제목, 글, 연도 또는 None) # (갈래, 제목, 글, 연도 또는 None)
# ★ 원문을 옮기지 않는다 — 시·소설·가사의 본문은 한 줄도 싣지 않고 제목과 배경만 쓴다. # ★ 원문을 옮기지 않는다 — 시·소설·가사의 본문은 한 줄도 싣지 않고 제목과 배경만 쓴다.
@ -117,6 +118,86 @@ ITEMS = [
"이어 시인이 나온 도시입니다. 이름만으로는 알기 어려워, '인물 열전' 탭에서 " "이어 시인이 나온 도시입니다. 이름만으로는 알기 어려워, '인물 열전' 탭에서 "
"한 사람씩 생애와 활동을 봅니다.", None), "한 사람씩 생애와 활동을 봅니다.", None),
# ── 인물 22 ───────────────────────────────────────────────────────────
# ★ 2026-09-18 대표: "'군산 읽기'는 인물·문학만 담아 랜덤 대신 전체 노출하고
# 약 30개로 추린다." 인물 열전(57명)은 한 줄짜리 레일이라 읽을 거리가 못 된다 —
# 여기서는 사람마다 세 문장 안쪽으로 **읽는 글**로 세운다.
# ★ 확인되는 것만 적는다. 사람은 살아 있는 이가 많아 더 위험하다 — 출생연도·직업·
# 대표 이력처럼 널리 알려진 것만 쓰고, 일화나 숫자는 보태지 않는다.
("인물", "채만식을 잇는 이름들",
"군산은 소설가 채만식만 낳은 도시가 아닙니다. 시인 고은과 이병훈·심호택·문효치·"
"이연주·주하림이 이 도시에서 나고 자랐고, 언어학자 김선기와 철학자 고형곤도 "
"여기 사람입니다. 문학과 학문 쪽 이름이 유난히 촘촘한 도시입니다.", None),
("인물", "김선기",
"1907년 군산에서 태어난 언어학자입니다. 호는 무돌이고, 한글과 국어 연구에 "
"힘쓴 학자이자 교육자였습니다. 1992년에 세상을 떠났습니다.", 1907),
("인물", "고형곤",
"1906년 군산에서 태어난 철학자입니다. 선(禪)을 철학으로 풀어낸 연구로 알려졌고 "
"대학에서 오래 가르쳤습니다. 아들은 국무총리를 지낸 고건입니다.", 1906),
("인물", "고건",
"1938년 군산에서 태어난 행정가입니다. 서울시장을 지냈고 제30·35대 국무총리를 "
"두 차례 맡았습니다. 아버지가 철학자 고형곤이라, 한 집에서 학문과 행정이 "
"갈라져 나온 셈입니다.", 1938),
("인물", "임병찬",
"1851년 옥구(지금의 군산)에서 태어난 구한말 의병장입니다. 1906년 최익현과 함께 "
"전북 태인에서 거병했고, 붙잡혀 대마도로 유배됐습니다. 돌아온 뒤 다시 독립운동을 "
"이어 가다 거문도에 갇혀 단식 끝에 순국했습니다.", 1851),
("인물", "이수현",
"1895년 군산에서 태어난 독립운동가입니다. 호는 산남이고, 일제강점기 내내 "
"독립운동에 힘썼습니다.", 1895),
("인물", "반석평",
"조선 중종 때 문신입니다. 노비의 신분에서 학문을 익혀 문과에 급제하고 판서에 "
"올랐습니다. 신분이 사람의 끝을 정하던 시대에 드문 이력이라, 군산이 꼽는 "
"옛 인물 가운데 앞자리에 섭니다.", 1472),
("인물", "이길여",
"1932년 옥구(지금의 군산)에서 태어난 의료인입니다. 인천에 산부인과를 열어 "
"병원을 키웠고, 그 병원이 지금의 가천대 길병원입니다. 가천대학교를 세워 "
"총장을 맡고 있습니다.", 1932),
("인물", "강봉균",
"1943년 군산에서 태어난 경제 관료입니다. 재정경제부 장관을 지냈고 군산에서 "
"3선 국회의원을 했습니다. 2017년에 세상을 떠났습니다.", 1943),
("인물", "김관영",
"1969년 군산에서 태어났습니다. 회계사와 변호사를 거쳐 국회의원을 지냈고, "
"지금은 전북특별자치도지사입니다.", 1969),
("인물", "김의겸",
"1963년 군산에서 태어난 언론인입니다. 신문 기자로 오래 일하다 청와대 대변인을 "
"맡았고, 뒤에 국회의원이 됐습니다.", 1963),
("인물", "최길선",
"1946년 군산에서 태어난 조선업 경영인입니다. 현장에서 시작해 현대중공업 "
"회장까지 올랐습니다.", 1946),
("인물", "김수미",
"1949년 군산에서 태어난 배우입니다. 드라마 「전원일기」의 일용 엄니로 오래 "
"기억되고, 뒤에는 영화와 예능에서도 활동했습니다. 2024년에 세상을 떠났습니다.", 1949),
("인물", "송새벽",
"1979년 군산에서 태어난 배우입니다. 연극 무대를 거쳐 영화로 옮겨 왔고, 특유의 "
"말투와 표정으로 알려졌습니다.", 1979),
("인물", "박화요비",
"1982년 군산에서 태어난 가수입니다. R&B 를 주로 부르며 방송과 무대에서 오래 "
"활동해 왔습니다.", 1982),
("인물", "박경완",
"1972년 군산에서 태어난 야구 포수입니다. SK 와이번스에서 오래 뛰며 한국 야구를 "
"대표하는 포수로 꼽혔고, 은퇴 뒤에는 코치로 일했습니다.", 1972),
("인물", "차우찬",
"1987년 군산에서 태어난 야구 투수입니다. 삼성과 LG 에서 뛰었고 지금은 해설위원 "
"으로 경기를 중계합니다.", 1987),
("인물", "오지환",
"1990년 군산에서 태어난 야구 선수입니다. LG 트윈스 내야수로 오래 뛰었습니다.", 1990),
("인물", "노상래",
"1970년 군산에서 태어난 축구 선수입니다. 공격수로 뛰었고 은퇴 뒤에는 지도자로 "
"일했습니다.", 1970),
("인물", "박성현",
"1983년 군산에서 태어난 양궁 선수입니다. 군산 소룡초등학교에서 활을 처음 잡아 "
"올림픽 금메달까지 갔습니다. 이 도시에서 나온 가장 널리 알려진 운동 선수 "
"가운데 하나입니다.", 1983),
("인물", "김광선",
"1964년 군산에서 태어난 권투 선수입니다. 1988년 서울 올림픽 복싱 플라이급에서 "
"금메달을 땄습니다.", 1964),
("인물", "한왕용",
"1966년 군산에서 태어난 산악인입니다. 히말라야 8000m 급 14좌를 세계에서 열한 "
"번째로 모두 올랐습니다.", 1966),
("인물", "문호준",
"1997년 군산에서 태어난 프로게이머입니다. 카트라이더 리그에서 가장 많이 우승한 "
"선수로 꼽힙니다.", 1997),
# ── 섬과 바다 6 ─────────────────────────────────────────────────────── # ── 섬과 바다 6 ───────────────────────────────────────────────────────
("섬과 바다", "고군산군도", ("섬과 바다", "고군산군도",
"예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, 그 뒤로 " "예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, 그 뒤로 "
@ -274,11 +355,12 @@ for group, title, body, year in ITEMS:
item["year"] = year item["year"] = year
items.append(item) items.append(item)
# 부제에 전체 개수를 내걸지 않는다 — 화면은 이 중 5~6개만 매번 무작위로 보여준다 # 2026-09-18 대표: "인물·문학만 담아 랜덤 대신 전체 노출하고 약 30개로 추린다."
# (2026-09-14 대표: "34개중 15~19개인데 왜 34개라고 써놔?"). 숫자 대신 "올 때마다 # 무작위 5~6개를 뽑던 것을 되돌렸다 — 카로셀은 슬라이드 하나 = 꼭지 하나라 전체를 실어도
# 다른"으로 적어 실제 화면과 문구가 어긋나지 않게 한다. # 세로로 길어지지 않는다(전에 스크롤 압박이 났던 것은 31꼭지를 한 화면에 펼쳤을 때다).
# 그래서 부제에 개수를 다시 내걸 수 있다 — 화면에 그만큼 다 선다.
env = {"kind": "reading", "version": 1, "title": "군산 읽기", env = {"kind": "reading", "version": 1, "title": "군산 읽기",
"subtitle": "이 도시를 쓴 사람과 그 자리", "subtitle": "이 도시를 쓴 사람과 이 도시가 낳은 사람",
"groups": [{"name": name, "note": note} for name, note in GROUPS], "groups": [{"name": name, "note": note} for name, note in GROUPS],
"items": items} "items": items}
@ -296,4 +378,4 @@ SRC.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
counts = {name: sum(1 for i in items if i["group"] == name) for name, _ in GROUPS} counts = {name: sum(1 for i in items if i["group"] == name) for name, _ in GROUPS}
print("군산 읽기 " + str(len(items)) + "가지 — " print("군산 읽기 " + str(len(items)) + "가지 — "
+ " · ".join(f"{k} {v}" for k, v in counts.items()) + " · ".join(f"{k} {v}" for k, v in counts.items())
+ " (화면엔 매번 5~6개만 무작위)") + " (화면에 전부 선다)")

View File

@ -39,9 +39,9 @@ PAGES = [
# 군산 페이지에는 필요 없어서 주입 css 로 감춘다. # 군산 페이지에는 필요 없어서 주입 css 로 감춘다.
("gunsan", "군산", {"hero", "festival", "local", "itinerary", "story", ("gunsan", "군산", {"hero", "festival", "local", "itinerary", "story",
"songs", "people", "chronicle", "reading"}, "songs", "people", "chronicle", "reading"},
{"reviews", "postcard-maker", "location"}), {"reviews", "postcard-maker", "location", "hero"}),
("booking", "예약·후기", {"hero", "info", "booking", "faq", "rules", "map"}, ("booking", "예약·후기", {"hero", "info", "booking", "faq", "rules", "map"},
{"postcard-maker"}), {"postcard-maker", "hero"}),
] ]
# 화면 앵커 id → 그 섹션이 사는 페이지. # 화면 앵커 id → 그 섹션이 사는 페이지.
@ -55,8 +55,20 @@ ANCHOR_PAGE = {
"info": "booking", "booking": "booking", "faq": "booking", "reviews": "booking", "info": "booking", "booking": "booking", "faq": "booking", "reviews": "booking",
} }
# 하위 페이지의 머리.
# ★ 실물 대조(2026-09-18, 星のや京都 /dining/ · 모바일 390×844): 하위 페이지에는 히어로가
# 없다. 얇은 헤더 → 여백 → **페이지 이름 한 줄(가운데·명조)** → 바로 내용이다.
# 사진도 캐치프레이즈도 CTA 도 없다. 홈 히어로를 하위 페이지가 되풀이하는 판은 없었다.
NAV_CSS = """ NAV_CSS = """
[data-w4d-here] { font-weight: 800; text-decoration: underline; text-underline-offset: 5px; } [data-w4d-here] { font-weight: 800; text-decoration: underline; text-underline-offset: 5px; }
.w4d-pagehead {
padding: clamp(2.5rem, 9vw, 4.5rem) 1.25rem clamp(1.75rem, 6vw, 3rem);
text-align: center;
}
.w4d-pagehead h1 {
margin: 0; font-family: var(--font-serif, serif); font-weight: 400;
font-size: clamp(1.75rem, 7vw, 2.4rem); letter-spacing: .04em; line-height: 1.3;
}
""" """
# ★ HTML 을 고쳐도 소용없다 — 리액트가 하이드레이션하면서 헤더를 다시 그려 덮는다(실측). # ★ HTML 을 고쳐도 소용없다 — 리액트가 하이드레이션하면서 헤더를 다시 그려 덮는다(실측).
@ -88,13 +100,31 @@ NAV_JS = """
event.preventDefault(); event.preventDefault();
location.href = to; location.href = to;
}, true); }, true);
[0, 400, 1200, 3000].forEach(function (ms) { setTimeout(fix, ms); }); var TITLE = %(title)s;
window.addEventListener('pageshow', fix); function head() {
if (!TITLE) return;
var header = document.querySelector('#root header');
if (!header || document.querySelector('.w4d-pagehead')) return;
var band = document.createElement('div');
band.className = 'w4d-pagehead';
var h1 = document.createElement('h1');
h1.textContent = TITLE;
band.appendChild(h1);
header.parentNode.insertBefore(band, header.nextSibling);
}
function tick() { fix(); head(); }
[0, 400, 1200, 3000].forEach(function (ms) { setTimeout(tick, ms); });
window.addEventListener('pageshow', tick);
if (window.MutationObserver) {
new MutationObserver(tick).observe(document.getElementById('root'), {childList: true, subtree: false});
}
})(); })();
</script> </script>
""" """
# 하위 페이지는 히어로를 쓰지 않는다(위 실물 대조). 렌더러가 무조건 그리므로 여기서 감춘다.
HIDE_CSS = { HIDE_CSS = {
"hero": "#top { display: none !important; }",
"reviews": "#reviews { display: none !important; }", "reviews": "#reviews { display: none !important; }",
"postcard-maker": "#postcard-maker { display: none !important; }", "postcard-maker": "#postcard-maker { display: none !important; }",
"location": "#location { display: none !important; }", "location": "#location { display: none !important; }",
@ -129,11 +159,15 @@ def variant(payload, keep):
return out return out
PAGE_TITLE = {"": None, "gunsan": "군산", "booking": "이용안내 및 예약"}
def nav_js(current): def nav_js(current):
return NAV_JS % { return NAV_JS % {
"base": json.dumps(BASE), "base": json.dumps(BASE),
"here": json.dumps(current), "here": json.dumps(current),
"page": json.dumps(ANCHOR_PAGE, ensure_ascii=False), "page": json.dumps(ANCHOR_PAGE, ensure_ascii=False),
"title": json.dumps(PAGE_TITLE[current], ensure_ascii=False),
} }

View File

@ -25,7 +25,7 @@
border: 0; border: 0;
border-radius: var(--tpl-radius, 0); border-radius: var(--tpl-radius, 0);
cursor: pointer; cursor: pointer;
opacity: 0.75; opacity: 0.88;
} }
#w4d-mini button:hover { opacity: 1; background: rgb(27 26 21 / 0.06); } #w4d-mini button:hover { opacity: 1; background: rgb(27 26 21 / 0.06); }
#w4d-mini svg { width: 16px; height: 16px; } #w4d-mini svg { width: 16px; height: 16px; }
@ -43,7 +43,7 @@
color: var(--tpl-accent, #bf2f1b); color: var(--tpl-accent, #bf2f1b);
padding-right: 2px; padding-right: 2px;
} }
#w4d-mini[data-playing='0'] #w4d-now { opacity: 0.85; font-weight: 400; } #w4d-mini[data-playing='0'] #w4d-now { opacity: 0.92; font-weight: 400; }
@media (max-width: 860px) { #w4d-now { display: none; } } @media (max-width: 860px) { #w4d-now { display: none; } }
/* 카세트 — 단추가 아니라 표지다. 이 자리가 무엇인지 한눈에 말해 준다. */ /* 카세트 — 단추가 아니라 표지다. 이 자리가 무엇인지 한눈에 말해 준다. */
@ -52,7 +52,7 @@
align-items: center; align-items: center;
padding: 0 4px 0 2px; padding: 0 4px 0 2px;
color: var(--tpl-text, #1b1a15); color: var(--tpl-text, #1b1a15);
opacity: 0.8; opacity: 0.9;
} }
#w4d-tape svg { width: 20px; height: 20px; } #w4d-tape svg { width: 20px; height: 20px; }
#w4d-mini[data-playing='1'] #w4d-tape { color: var(--tpl-accent, #bf2f1b); opacity: 1; } #w4d-mini[data-playing='1'] #w4d-tape { color: var(--tpl-accent, #bf2f1b); opacity: 1; }
@ -101,7 +101,7 @@
border-bottom: var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15); border-bottom: var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15);
} }
.w4d-head-t { font-size: 15px; font-weight: 700; letter-spacing: 0.06em; } .w4d-head-t { font-size: 15px; font-weight: 700; letter-spacing: 0.06em; }
.w4d-head-n { flex: 1 1 auto; font-size: 14px; opacity: 0.5; } .w4d-head-n { flex: 1 1 auto; font-size: 14px; opacity: 0.75; }
#w4d-panel .w4d-head button { #w4d-panel .w4d-head button {
display: inline-flex; display: inline-flex;
width: 28px; width: 28px;
@ -113,7 +113,7 @@
background: transparent; background: transparent;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
opacity: 0.6; opacity: 0.8;
} }
#w4d-panel .w4d-head button:hover { opacity: 1; } #w4d-panel .w4d-head button:hover { opacity: 1; }
#w4d-panel .w4d-head svg { width: 14px; height: 14px; } #w4d-panel .w4d-head svg { width: 14px; height: 14px; }
@ -203,12 +203,12 @@
font-size: 14px; font-size: 14px;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
opacity: 0.85; opacity: 0.92;
} }
.w4d-time { .w4d-time {
flex: 0 0 auto; flex: 0 0 auto;
font-size: 14px; font-size: 14px;
opacity: 0.6; opacity: 0.8;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@ -229,7 +229,7 @@
#root div.border-line.flex.flex-col.gap-3.border-t.pt-6 { border-top-width: 0; } #root div.border-line.flex.flex-col.gap-3.border-t.pt-6 { border-top-width: 0; }
/* 사진 저작자 표시 — 출처 줄과 같은 크기, 한 단 더 옅게. */ /* 사진 저작자 표시 — 출처 줄과 같은 크기, 한 단 더 옅게. */
.w4d-credit { margin-top: 2px; font-size: 14px; opacity: 0.85; } .w4d-credit { margin-top: 2px; font-size: 14px; opacity: 0.92; }
/* 모바일 헤더에서 전화 단추를 접는다 (2026-09-11 사장님: "저 전화 아이콘은 빼도 되잖아"). /* 모바일 헤더에서 전화 단추를 접는다 (2026-09-11 사장님: "저 전화 아이콘은 빼도 되잖아").
좁은 화면 헤더에 상호 + 플레이어 3개 + 전화 + 메뉴가 서서 난잡했다. 좁은 화면 헤더에 상호 + 플레이어 3개 + 전화 + 메뉴가 서서 난잡했다.
@ -258,7 +258,7 @@
문장 길이가 달라 줄이 오갈 히어로 아래가 튀는 것을 막는다. */ 문장 길이가 달라 줄이 오갈 히어로 아래가 튀는 것을 막는다. */
/* 대표 문구 아래에서 도는 (2026-09-11). 대표 문구보다 작고 옅게 둔다 /* 대표 문구 아래에서 도는 (2026-09-11). 대표 문구보다 작고 옅게 둔다
둘이 같은 크기·같은 농도면 어느 쪽이 숙소의 줄인지 읽힌다. */ 둘이 같은 크기·같은 농도면 어느 쪽이 숙소의 줄인지 읽힌다. */
.w4d-sub { margin-top: 0.5rem; font-size: var(--fs-sm, 0.9375rem); line-height: 1.6; opacity: 0.75; } .w4d-sub { margin-top: 0.5rem; font-size: var(--fs-sm, 0.9375rem); line-height: 1.6; opacity: 0.88; }
.w4d-line { display: block; } .w4d-line { display: block; }
.w4d-line--out { .w4d-line--out {
opacity: 0; opacity: 0;
@ -349,7 +349,7 @@
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;
gap: 14px; gap: 14px;
padding: 6px 4px 14px; padding: 6px 4px 22px;
/* 렌더러가 div 자체에 mt-4 지운다 지금은 div `.slider-viewport` 안에 /* 렌더러가 div 자체에 mt-4 지운다 지금은 div `.slider-viewport` 안에
들어가 있어, 여백이 화살표 () 아니라 스크롤 상자 안쪽 공간이 됐다. 들어가 있어, 여백이 화살표 () 아니라 스크롤 상자 안쪽 공간이 됐다.
간격은 화살표 줄의 mt-4(inject.js `startSongsRail`) 대신 준다. */ 간격은 화살표 줄의 mt-4(inject.js `startSongsRail`) 대신 준다. */
@ -369,6 +369,7 @@
#songs button:has(> .w4-disc-mini) > .w4-disc-mini + span { #songs button:has(> .w4-disc-mini) > .w4-disc-mini + span {
margin-top: 5px; margin-top: 5px;
font-size: 14px; font-size: 14px;
line-height: 1.4;
} }
/* 고른 판만 살짝 들어 올린다 "뽑아 든 판" 표시. 선택 자체(테두리) React 인라인 /* 고른 판만 살짝 들어 올린다 "뽑아 든 판" 표시. 선택 자체(테두리) React 인라인
스타일로 이미 그린다(`boxShadow`); 여기선 위에 얹는 동작만 더한다. */ 스타일로 이미 그린다(`boxShadow`); 여기선 위에 얹는 동작만 더한다. */
@ -383,6 +384,13 @@
#songs .slider-viewport { #songs .slider-viewport {
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%); -webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%);
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%); mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%);
/* 원판이 위아래로 잘렸다 (2026-09-18 대표: "원판 아직도 잘린다.. overflow hidden풀어").
안쪽 :has() div padding 늘려도 소용없었다 실제로 자르는 경계는 뷰포트다.
`.slider-viewport` overflow-x hidden/auto 정하는데, 브라우저는 축이라도
visible 아니면 나머지 축도 auto 계산한다(CSS Overflow 스펙) 세로도 상자의
padding-box 에서 잘린다. 판이 hover·선택으로 최대 6px 떠오르고 그림자가 위로
번지므로, 뷰포트 자체에 여유를 준다. */
padding-block: 14px 4px;
} }
/* /*
@ -449,7 +457,7 @@
border: 1px solid var(--tpl-border, #bcb49e); border: 1px solid var(--tpl-border, #bcb49e);
resize: none; /* 2026-09-14 대표: "textarea 크기 못바꾸게" — 늘리면 옆 캔버스와 높이가 어긋난다 */ resize: none; /* 2026-09-14 대표: "textarea 크기 못바꾸게" — 늘리면 옆 캔버스와 높이가 어긋난다 */
} }
.w4d-pm-count { margin: 4px 0 14px; font-size: 14px; text-align: right; opacity: 0.85; } .w4d-pm-count { margin: 4px 0 14px; font-size: 14px; text-align: right; opacity: 0.92; }
.w4d-pm-actions { display: flex; flex-wrap: wrap; gap: 10px; } .w4d-pm-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.w4d-pm-actions button { .w4d-pm-actions button {
padding: 9px 16px; padding: 9px 16px;
@ -460,7 +468,7 @@
} }
.w4d-pm-share { background: var(--tpl-primary, #1b1a15); color: var(--tpl-bg, #fff); } .w4d-pm-share { background: var(--tpl-primary, #1b1a15); color: var(--tpl-bg, #fff); }
.w4d-pm-save { background: transparent; color: var(--tpl-text, #1b1a15); } .w4d-pm-save { background: transparent; color: var(--tpl-text, #1b1a15); }
.w4d-pm-hint { margin: 10px 0 0; font-size: 14px; opacity: 0.6; } .w4d-pm-hint { margin: 10px 0 0; font-size: 14px; opacity: 0.8; }
@media (max-width: 480px) { @media (max-width: 480px) {
.w4d-pm-canvas { width: 100%; } .w4d-pm-canvas { width: 100%; }
} }
@ -508,7 +516,7 @@
letter-spacing: 0.18em; letter-spacing: 0.18em;
color: color-mix(in oklab, var(--tpl-accent, #bf2f1b) 62%, var(--tpl-text, #1b1a15)); color: color-mix(in oklab, var(--tpl-accent, #bf2f1b) 62%, var(--tpl-text, #1b1a15));
} }
.w4d-read-year { font-weight: 400; letter-spacing: 0.06em; opacity: 0.7; } .w4d-read-year { font-weight: 400; letter-spacing: 0.06em; opacity: 0.85; }
.w4d-read-card h3 { .w4d-read-card h3 {
margin: 0 0 10px; margin: 0 0 10px;
padding-bottom: 10px; padding-bottom: 10px;
@ -524,7 +532,7 @@
line-height: 1.8; line-height: 1.8;
text-align: justify; text-align: justify;
word-break: keep-all; word-break: keep-all;
opacity: 0.88; opacity: 0.94;
} }
.w4d-read-src { .w4d-read-src {
margin-top: 14px; margin-top: 14px;
@ -532,7 +540,7 @@
border-top: 1px dashed var(--tpl-border, #bcb49e); border-top: 1px dashed var(--tpl-border, #bcb49e);
font-size: 14px; font-size: 14px;
font-style: italic; font-style: italic;
opacity: 0.6; opacity: 0.8;
} }
.w4d-read-src a { color: inherit; text-decoration: underline; text-underline-offset: 2px; } .w4d-read-src a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
/* .w4d-read-flag(확인필요 배지) 삭제 2026-09-14 대표: "확인필요 없애고 링크는 /* .w4d-read-flag(확인필요 배지) 삭제 2026-09-14 대표: "확인필요 없애고 링크는
@ -588,7 +596,7 @@
color: var(--tpl-bg, #fff); color: var(--tpl-bg, #fff);
font-weight: 700; font-weight: 700;
} }
.w4d-gal-total { margin-left: 8px; font-size: 14px; opacity: 0.85; } .w4d-gal-total { margin-left: 8px; font-size: 14px; opacity: 0.92; }
} }
/* 768~1023px 에서 하단 탭바가 푸터 마지막 줄을 덮는 막기 /* 768~1023px 에서 하단 탭바가 푸터 마지막 줄을 덮는 막기

View File

@ -767,7 +767,7 @@
}); });
html += '</ul>'; html += '</ul>';
} }
html += '<span class="w4d-read-counter text-current/55 text-[length:var(--fs-xs)] tabular-nums">' + html += '<span class="w4d-read-counter text-current/90 text-[length:var(--fs-xs)] tabular-nums">' +
'<span data-cur>1</span> / ' + picked.length + '</span></div>'; '<span data-cur>1</span> / ' + picked.length + '</span></div>';
var panel = document.createElement('div'); var panel = document.createElement('div');
@ -1009,7 +1009,7 @@
// 카운터 — Carousel.tsx 도 점(dots)이 12개를 넘으면 점은 숨기고 이 숫자 줄만 남긴다. // 카운터 — Carousel.tsx 도 점(dots)이 12개를 넘으면 점은 숨기고 이 숫자 줄만 남긴다.
// 여기는 25~50곡이라 항상 그 경우다. // 여기는 25~50곡이라 항상 그 경우다.
var counter = document.createElement('p'); var counter = document.createElement('p');
counter.className = 'mt-2 text-center text-current/55 text-[length:var(--fs-xs)] tabular-nums'; counter.className = 'mt-2 text-center text-current/90 text-[length:var(--fs-xs)] tabular-nums';
counter.innerHTML = '<span data-cur>1</span> / ' + track.children.length; counter.innerHTML = '<span data-cur>1</span> / ' + track.children.length;
viewport.parentNode.insertBefore(counter, viewport.nextSibling); viewport.parentNode.insertBefore(counter, viewport.nextSibling);
@ -1876,7 +1876,7 @@
if (footer.querySelector('a[href="' + HREF + '"]')) return true; // 번들이 이미 그렸다 if (footer.querySelector('a[href="' + HREF + '"]')) return true; // 번들이 이미 그렸다
var box = footer.querySelector('.shell') || footer.firstElementChild || footer; var box = footer.querySelector('.shell') || footer.firstElementChild || footer;
var p = document.createElement('p'); var p = document.createElement('p');
p.className = 'pt-3 text-[length:var(--fs-xs)] opacity-55'; p.className = 'pt-3 text-[length:var(--fs-xs)] opacity-85';
p.innerHTML = '<a href="' + HREF + '" target="_blank" rel="noopener noreferrer" ' + p.innerHTML = '<a href="' + HREF + '" target="_blank" rel="noopener noreferrer" ' +
'class="underline-offset-2 hover:underline">AI O2O</a>의 Web4Ai로 만든 사이트입니다.'; 'class="underline-offset-2 hover:underline">AI O2O</a>의 Web4Ai로 만든 사이트입니다.';
box.appendChild(p); box.appendChild(p);

View File

@ -453,9 +453,9 @@ for _section in payload["theme"]["sections"]:
# **있는데 꺼진** 상태라 `hasSection()` 이 참이고, 그 분기는 "사장님이 끈 것" 으로 존중한다. # **있는데 꺼진** 상태라 `hasSection()` 이 참이고, 그 분기는 "사장님이 끈 것" 으로 존중한다.
# ★ 지어낸 값이 없다. 요금·인원·취소 규정은 payload 의 확인된 fact 뿐이고 # ★ 지어낸 값이 없다. 요금·인원·취소 규정은 payload 의 확인된 fact 뿐이고
# (`derive.ts stayBookingView`), 근거가 하나도 없으면 섹션째 안 그린다. # (`derive.ts stayBookingView`), 근거가 하나도 없으면 섹션째 안 그린다.
for _section in payload["theme"]["sections"]: # for _section in payload["theme"]["sections"]:
if _section["id"] == "booking": # if _section["id"] == "booking":
_section["enabled"] = True # _section["enabled"] = True
# ── 네이버 플레이스 → 네이버 예약 ──────────────────────────────────────────── # ── 네이버 플레이스 → 네이버 예약 ────────────────────────────────────────────
for _link in payload["links"]: for _link in payload["links"]:
@ -466,6 +466,12 @@ for _link in payload["links"]:
# ── index.html 조립 ────────────────────────────────────────────────────────── # ── index.html 조립 ──────────────────────────────────────────────────────────
html = (SP / "orig" / "index.html").read_text(encoding="utf-8") html = (SP / "orig" / "index.html").read_text(encoding="utf-8")
_FADED = {"50": "85", "55": "85", "60": "85", "65": "90", "70": "90", "75": "90",
"80": "95", "85": "95"}
html = re.sub(r"(?<![-\w])opacity-(\d+)(?![-\w])",
lambda m: "opacity-" + _FADED.get(m.group(1), m.group(1)), html)
html = html.replace("text-current/55", "text-current/90").replace("text-current/80", "text-current/90")
# :root 의 --tpl-* 색상은 payload 의 window.__SITE_PAYLOAD__ 와 별도로 굳어 있던 값이다 # :root 의 --tpl-* 색상은 payload 의 window.__SITE_PAYLOAD__ 와 별도로 굳어 있던 값이다
# (원본 프리렌더 시점 그대로) — theme.colors 를 바꿔도 이 블록은 그대로라 배경색 변경이 # (원본 프리렌더 시점 그대로) — theme.colors 를 바꿔도 이 블록은 그대로라 배경색 변경이
# 절반만 반영됐다(소개 섹션은 --tpl-surface, 이용안내는 --tpl-surface-alt 를 쓴다). # 절반만 반영됐다(소개 섹션은 --tpl-surface, 이용안내는 --tpl-surface-alt 를 쓴다).
@ -477,7 +483,11 @@ _colors = payload["theme"]["colors"]
_tpl_vars = { _tpl_vars = {
"--tpl-primary": _colors["primary"], "--tpl-secondary": _colors["secondary"], "--tpl-primary": _colors["primary"], "--tpl-secondary": _colors["secondary"],
"--tpl-bg": _colors["bg"], "--tpl-card": _colors["card"], "--tpl-text": _colors["text"], "--tpl-bg": _colors["bg"], "--tpl-card": _colors["card"], "--tpl-text": _colors["text"],
"--tpl-accent": _colors["accent"], "--tpl-surface": _colors["surface"], "--tpl-accent": _colors["accent"],
# ★ 원본 payload(stay-payload.json)에는 surface 키가 **없다** — 프리렌더가 :root 에 박아
# 둔 고정값이 유일한 출처다(실측 #e5dcc6). 대괄호로 읽으면 KeyError 로 굽기가 통째로
# 멈춘다(2026-09-18 실측: 이 줄에서 죽어 /s/stay 를 다시 구울 수 없었다).
"--tpl-surface": _colors.get("surface", "#e5dcc6"),
"--tpl-surface-alt": _colors["card"], "--tpl-surface-alt": _colors["card"],
} }
for _name, _value in _tpl_vars.items(): for _name, _value in _tpl_vars.items():
@ -509,7 +519,7 @@ assert OLD_UPDATED_AT not in html and OLD_UPDATED_AT[:10] not in html, "옛 날
# ★ 화면이 실제로 따라오는 것은 `vendor/` 번들을 다시 복사한 다음이다(아래 ★ 갱신 절차) — # ★ 화면이 실제로 따라오는 것은 `vendor/` 번들을 다시 복사한 다음이다(아래 ★ 갱신 절차) —
# 지금 넣는 것은 크롤러와 첫 화면이 보는 값이고, 날짜를 payload 밖에서도 고치는 것과 # 지금 넣는 것은 크롤러와 첫 화면이 보는 값이고, 날짜를 payload 밖에서도 고치는 것과
# 같은 이유다. # 같은 이유다.
MADE_BY = ('<p class="pt-3 text-[length:var(--fs-xs)] opacity-55">' MADE_BY = ('<p class="pt-3 text-[length:var(--fs-xs)] opacity-85">'
'<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer" ' '<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer" '
'class="underline-offset-2 hover:underline">AI O2O</a>' 'class="underline-offset-2 hover:underline">AI O2O</a>'
'의 Web4Ai로 만든 사이트입니다.</p>') '의 Web4Ai로 만든 사이트입니다.</p>')

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,7 @@ import {Shell as OasiShell} from '@site/layouts/oasi/Shell';
import {Shell as StudioShell} from '@site/layouts/studio/Shell'; import {Shell as StudioShell} from '@site/layouts/studio/Shell';
import {Shell as PastelShell} from '@site/layouts/pastel/Shell'; import {Shell as PastelShell} from '@site/layouts/pastel/Shell';
import {Shell as EditorialShell} from '@site/layouts/editorial/Shell'; import {Shell as EditorialShell} from '@site/layouts/editorial/Shell';
import {Shell as PaperShell} from '@site/layouts/paper/Shell';
import {HomePage} from '@site/pages'; import {HomePage} from '@site/pages';
/** /**
@ -39,7 +40,9 @@ export function App({payload}: {payload: SitePayload}) {
? PastelShell ? PastelShell
: layout === 'editorial' : layout === 'editorial'
? EditorialShell ? EditorialShell
: DefaultShell; : layout === 'paper'
? PaperShell
: DefaultShell;
return ( return (
<SiteProvider payload={payload}> <SiteProvider payload={payload}>

View File

@ -154,7 +154,7 @@ export function Shell({children}: {children: ReactNode}) {
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline underline-offset-4 opacity-80 transition-opacity hover:opacity-100" className="underline underline-offset-4 opacity-90 transition-opacity hover:opacity-100"
> >
{channelLabel(link)} {channelLabel(link)}
</a> </a>

View File

@ -0,0 +1,124 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
const ROTATE_MS = 6000;
const MAX_SLIDES = 5;
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const locality = place.addressLocality ?? place.addressRegion;
const slides = [...payload.media]
.filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, MAX_SLIDES);
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const touchX = useRef<number | null>(null);
const step = useCallback(
(delta: number) => setIndex((now) => (now + delta + slides.length) % slides.length),
[slides.length],
);
useEffect(() => {
if (paused || slides.length < 2) return;
const timer = setInterval(() => step(1), ROTATE_MS);
return () => clearInterval(timer);
}, [paused, step, slides.length]);
return (
<section id="top" className="w-full">
<div
className="relative w-full overflow-hidden"
style={{height: 'clamp(24rem, 74vh, 40rem)'}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
onTouchStart={(event) => {
touchX.current = event.touches[0].clientX;
}}
onTouchEnd={(event) => {
const from = touchX.current;
touchX.current = null;
if (from === null) return;
const moved = event.changedTouches[0].clientX - from;
if (Math.abs(moved) > 40) step(moved < 0 ? 1 : -1);
}}
>
{slides.map((image, i) => (
<img
key={image.mediaId}
src={image.url}
alt={image.alt}
fetchPriority={i === 0 ? 'high' : 'low'}
loading={i === 0 ? undefined : 'lazy'}
decoding="async"
className="absolute inset-0 size-full object-cover object-center transition-opacity duration-700"
style={{opacity: i === index ? 1 : 0}}
/>
))}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 42%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 10%, transparent) 40%, transparent 70%)',
}}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-start gap-2 px-6 pb-10 sm:px-10 sm:pb-14">
{locality && (
<p
className="text-[length:var(--fs-xs)] uppercase opacity-100"
style={{letterSpacing: '0.28em', color: 'var(--tpl-bg, #fff)'}}
>
{locality}
</p>
)}
<h1
className="serif"
style={{
fontSize: 'var(--fs-display)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
lineHeight: 1.3,
color: 'var(--tpl-bg, #fff)',
}}
>
{place.name}
</h1>
{(narrative.tagline ?? narrative.heroSubline) && (
<p
className="measure text-[length:var(--fs-lead)] leading-relaxed opacity-100"
style={{color: 'var(--tpl-bg, #fff)'}}
>
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase>
</p>
)}
</div>
{slides.length > 1 && (
<ul className="absolute inset-x-0 bottom-5 flex items-center justify-center gap-2 sm:hidden">
{slides.map((image, i) => (
<li key={image.mediaId}>
<button
type="button"
onClick={() => setIndex(i)}
aria-label={`${i + 1}번째 사진`}
aria-current={i === index}
className={`h-1.5 rounded-full transition-all ${i === index ? 'w-7' : 'w-1.5'}`}
style={{backgroundColor: 'var(--tpl-bg, #fff)', opacity: i === index ? 1 : 0.5}}
/>
</li>
))}
</ul>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,139 @@
import {Phone} from 'lucide-react';
import type {ChannelLink} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Carousel, CarouselSlide} from '@site/lib/ui';
import {SectionHead} from './SectionHead';
export function Rooms() {
const payload = useSite();
const units = unitViews(payload);
const spec = unitSpec(payload);
const booking = bookingLinks(payload)[0];
if (units.length === 0) return null;
return (
<section
id="units"
aria-labelledby="units-heading"
className="border-line w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #fff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<SectionHead
id="units"
title={sectionName(payload, spec.path, `${spec.label} ${units.length}개 안내`)}
/>
</div>
<div className="flex flex-col">
{units.map((unit, i) => (
<UnitPanel key={unit.unitId} unit={unit} no={i + 1} booking={booking} phone={payload.place.phone} />
))}
</div>
</section>
);
}
function UnitPanel({
unit,
no,
booking,
phone,
}: {
unit: UnitView;
no: number;
booking?: ChannelLink;
phone?: string;
}) {
return (
<article className="border-line border-t">
{unit.images.length > 0 && (
<Carousel label={`${unit.name} 사진`} align="center" gap={0} arrows="overlay">
{unit.images.map((image) => (
<CarouselSlide key={image.mediaId} basis="basis-full">
<div className="relative aspect-3/2 overflow-hidden">
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
</div>
</CarouselSlide>
))}
</Carousel>
)}
<div className="shell flex flex-col gap-3 py-6">
<p
className="text-[length:var(--fs-xs)] uppercase opacity-70"
style={{letterSpacing: '0.24em'}}
>
{String(no).padStart(2, '0')}
</p>
<h3
className="serif"
style={{
fontSize: 'clamp(1.1rem, 2.4vw, 1.4rem)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
}}
>
{unit.name}
</h3>
{unit.intro && (
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-100">{unit.intro}</p>
)}
{unit.rows.length > 0 && (
<dl className="border-line divide-line mt-2 divide-y border-t">
{unit.rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-4 py-2 text-[length:var(--fs-sm)]">
<dt className="text-muted shrink-0">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
{unit.chips.length > 0 && (
<ul className="mt-1 flex flex-wrap gap-1.5">
{unit.chips.map((chip) => (
<li
key={chip.label}
className="border-line inline-flex items-center rounded-full border px-3 py-1 text-[length:var(--fs-xs)]"
>
{chip.value}
</li>
))}
</ul>
)}
{(booking || phone) && (
<div className="mt-2 flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-semibold">
{booking && (
<a
href={booking.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center underline underline-offset-4"
>
{bookingActionLabel(booking)}
</a>
)}
{phone && (
<a href={`tel:${phone}`} className="tap inline-flex items-center gap-1.5 underline underline-offset-4">
<Phone className="size-4" />
<span>{phone}</span>
</a>
)}
</div>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,38 @@
import type {ReactNode} from 'react';
export function SectionHead({
title,
lead,
aside,
}: {
id: string;
title: string;
lead?: string;
aside?: ReactNode;
}) {
return (
<header className="mb-6 sm:mb-8">
<div aria-hidden className="border-line mb-4 w-full border-t" />
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h2
className="serif"
style={{
fontSize: 'clamp(1.15rem, 2.4vw, 1.5rem)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
}}
>
{title}
</h2>
{lead && (
<p className="text-muted measure mt-2 text-[length:var(--fs-sm)] leading-relaxed">
{lead}
</p>
)}
</div>
{aside && <div className="shrink-0">{aside}</div>}
</div>
</header>
);
}

View File

@ -0,0 +1,230 @@
import {useEffect, useState, type ReactNode} from 'react';
import {Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
import {enabledSections} from '@site/lib/derive';
import {isoDate} from '@site/lib/format';
import {MobileTabBar} from '@site/sections/MobileTabBar';
import {SongPlayer} from '@site/sections/SongPlayer';
interface TabEntry {
label: string;
anchor: string;
}
const ANCHOR: Record<string, string> = {
intro: 'about',
info: 'info',
rooms: 'units',
menu: 'units',
programs: 'units',
pricing: 'pricing',
booking: 'booking',
space: 'space',
inquiry: 'inquiry',
exhibition: 'exhibition',
photos: 'gallery',
local: 'guide',
weather: 'weather',
map: 'location',
faq: 'faq',
songs: 'songs',
daily: 'daily',
chronicle: 'chronicle',
reading: 'reading',
people: 'people',
quiz: 'quiz',
postcard: 'postcard',
video: 'video',
itinerary: 'itinerary',
};
const FALLBACK_LABEL: Record<string, string> = {
about: '소개',
info: '이용 정보',
units: '객실',
pricing: '요금',
booking: '예약 안내',
space: '공간',
inquiry: '문의',
exhibition: '관람 안내',
gallery: '사진',
guide: '주변',
weather: '날씨',
location: '오시는 길',
faq: '자주 묻는 질문',
songs: '노래',
daily: '일력',
chronicle: '연표',
reading: '읽기',
people: '인물',
quiz: '퀴즈',
postcard: '엽서',
video: '영상',
itinerary: '일정',
};
function useTabEntries(): TabEntry[] {
const payload = useSite();
const seen = new Set<string>();
const entries: TabEntry[] = [];
for (const section of enabledSections(payload)) {
const anchor = ANCHOR[section.id];
if (!anchor || seen.has(anchor)) continue;
if (anchor === 'units' && payload.units.length === 0) continue;
seen.add(anchor);
entries.push({label: section.name?.trim() || FALLBACK_LABEL[anchor] || anchor, anchor});
}
if (!seen.has('location')) entries.push({label: '오시는 길', anchor: 'location'});
return entries;
}
function useActiveAnchor(entries: TabEntry[]): string {
const anchors = entries.map((entry) => entry.anchor).join(',');
const [active, setActive] = useState('');
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') return;
const targets = anchors
.split(',')
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(records) => {
const hit = records.find((record) => record.isIntersecting);
if (hit) setActive(hit.target.id);
},
{rootMargin: '-45% 0px -50% 0px'},
);
targets.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [anchors]);
return active;
}
export function Shell({children}: {children: ReactNode}) {
const payload = useSite();
const {place, site} = payload;
const entries = useTabEntries();
const active = useActiveAnchor(entries);
const address = place.roadAddress ?? place.address;
return (
<div
className="flex min-h-screen w-full flex-col"
style={{backgroundColor: 'var(--tpl-bg, #fff)', color: 'var(--tpl-text, #1a1a1a)'}}
>
<header
className="border-line safe-t sticky top-0 z-40 w-full border-b backdrop-blur-md"
style={{backgroundColor: 'color-mix(in srgb, var(--tpl-surface, #fff) 90%, transparent)'}}
>
<div className="shell flex h-14 items-center justify-between gap-3">
<a
href="#top"
className="serif min-w-0 truncate text-[length:var(--fs-lead)]"
style={{fontWeight: 'var(--tpl-heading-weight, 400)'}}
>
{place.name}
</a>
<div className="flex shrink-0 items-center gap-1.5">
<SongPlayer />
{place.phone && (
<a
href={`tel:${place.phone}`}
className="tap flex size-9 items-center justify-center rounded-full"
aria-label="전화 걸기"
>
<Phone className="size-4" />
</a>
)}
</div>
</div>
{entries.length > 0 && (
<nav aria-label="주요 메뉴" className="border-line border-t">
<ul className="shell flex gap-5 overflow-x-auto">
{entries.map((entry) => {
const on = entry.anchor === active;
return (
<li key={entry.anchor} className="shrink-0">
<a
href={`#${entry.anchor}`}
aria-current={on ? 'true' : undefined}
className="tap flex h-11 items-center whitespace-nowrap text-[length:var(--fs-sm)]"
style={{
fontWeight: on ? 700 : 400,
boxShadow: on ? 'inset 0 -2px 0 currentColor' : undefined,
}}
>
{entry.label}
</a>
</li>
);
})}
</ul>
</nav>
)}
</header>
<div className="flex w-full flex-1 flex-col">{children}</div>
<footer
className="w-full pb-28 pt-12 md:pb-14"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
>
<div className="shell flex flex-col gap-4 text-[length:var(--fs-sm)]">
<p className="serif" style={{fontSize: 'var(--fs-lead)', fontWeight: 'var(--tpl-heading-weight, 400)'}}>
{place.name}
</p>
{address && <p className="opacity-100">{address}</p>}
{place.phone && (
<p>
<a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">
{place.phone}
</a>
</p>
)}
<div className="border-line flex flex-wrap items-center gap-x-3 gap-y-1 border-t pt-4 text-[length:var(--fs-xs)] opacity-100">
<span>: {place.name}</span>
{place.legal?.representative && <span>: {place.legal.representative}</span>}
{place.legal?.businessRegistrationNumber && (
<span>: {place.legal.businessRegistrationNumber}</span>
)}
{place.legal?.mailOrderNumber && <span>: {place.legal.mailOrderNumber}</span>}
{place.legal?.licenseNumber && (
<span>
{place.legal.licenseLabel ?? '인허가번호'}: {place.legal.licenseNumber}
</span>
)}
</div>
<p className="byline text-[length:var(--fs-xs)] opacity-100">
<span className="author">· {place.name}</span>
{' · 최종 업데이트: '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
</p>
<p className="text-[length:var(--fs-xs)] opacity-100">
<a
href="https://www.o2osolution.ai/"
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
AI O2O
</a>
Web4Ai로 .
</p>
</div>
</footer>
<MobileTabBar />
</div>
);
}

View File

@ -0,0 +1,4 @@
export {Shell} from './Shell';
export {SectionHead} from './SectionHead';
export {Hero} from './Hero';
export {Rooms} from './Rooms';

View File

@ -38,7 +38,7 @@ export function SectionHead({
{/* 설명은 한 줄이다. 없으면 그리지 않는다 — 없는 문구를 지어내지 않는다. */} {/* 설명은 한 줄이다. 없으면 그리지 않는다 — 없는 문구를 지어내지 않는다. */}
{lead && ( {lead && (
<p className="measure text-[length:var(--fs-sm)] leading-relaxed opacity-65">{lead}</p> <p className="measure text-[length:var(--fs-sm)] leading-relaxed opacity-85">{lead}</p>
)} )}
{aside && <div className="text-[length:var(--fs-sm)]">{aside}</div>} {aside && <div className="text-[length:var(--fs-sm)]">{aside}</div>}

View File

@ -13,7 +13,7 @@ import {useSite} from '@site/lib/site-context';
* ( `site_service.set_template`). * ( `site_service.set_template`).
* . `default` . * . `default` .
*/ */
export type LayoutId = 'default' | 'reservation' | 'oasi' | 'studio' | 'pastel' | 'editorial'; export type LayoutId = 'default' | 'reservation' | 'oasi' | 'studio' | 'pastel' | 'editorial' | 'paper';
const LAYOUT_BY_TEMPLATE: Record<string, LayoutId> = { const LAYOUT_BY_TEMPLATE: Record<string, LayoutId> = {
'stay-reservation': 'reservation', 'stay-reservation': 'reservation',
@ -21,6 +21,9 @@ const LAYOUT_BY_TEMPLATE: Record<string, LayoutId> = {
'stay-studio': 'studio', 'stay-studio': 'studio',
'stay-pastel': 'pastel', 'stay-pastel': 'pastel',
'stay-editorial': 'editorial', 'stay-editorial': 'editorial',
'stay-paper': 'paper',
'restaurant-paper': 'paper',
'cafe-paper': 'paper',
}; };
export function layoutOf(templateId: string | undefined): LayoutId { export function layoutOf(templateId: string | undefined): LayoutId {

View File

@ -257,7 +257,7 @@ export function Carousel({
))} ))}
</ul> </ul>
)} )}
<span className="text-current/80 text-[length:var(--fs-xs)] tabular-nums"> <span className="text-current/90 text-[length:var(--fs-xs)] tabular-nums">
{selected + 1} / {snaps.length} {selected + 1} / {snaps.length}
</span> </span>
</div> </div>

View File

@ -20,6 +20,7 @@ import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead'; import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead'; import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead'; import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
import {SectionHead as PaperHead} from '@site/layouts/paper/SectionHead';
export type SectionTone = 'base' | 'alt' | 'dark'; export type SectionTone = 'base' | 'alt' | 'dark';
@ -72,7 +73,9 @@ export function Section({
? PastelHead ? PastelHead
: layout === 'editorial' : layout === 'editorial'
? EditorialHead ? EditorialHead
: DefaultHead; : layout === 'paper'
? PaperHead
: DefaultHead;
return ( return (
<section <section

View File

@ -11,6 +11,7 @@ import {Hero as OasiHero} from '@site/layouts/oasi/Hero';
import {Hero as StudioHero} from '@site/layouts/studio/Hero'; import {Hero as StudioHero} from '@site/layouts/studio/Hero';
import {Hero as PastelHero} from '@site/layouts/pastel/Hero'; import {Hero as PastelHero} from '@site/layouts/pastel/Hero';
import {Hero as EditorialHero} from '@site/layouts/editorial/Hero'; import {Hero as EditorialHero} from '@site/layouts/editorial/Hero';
import {Hero as PaperHero} from '@site/layouts/paper/Hero';
/** /**
* . * .
@ -33,6 +34,7 @@ export function HeroSection() {
if (layout === 'studio') return <StudioHero />; if (layout === 'studio') return <StudioHero />;
if (layout === 'pastel') return <PastelHero />; if (layout === 'pastel') return <PastelHero />;
if (layout === 'editorial') return <EditorialHero />; if (layout === 'editorial') return <EditorialHero />;
if (layout === 'paper') return <PaperHero />;
const heroVariant = payload.theme.sections.find((s) => s.id === 'hero')?.variantId; const heroVariant = payload.theme.sections.find((s) => s.id === 'hero')?.variantId;
if (heroVariant === 'hero.slideshow') return <HeroPension />; if (heroVariant === 'hero.slideshow') return <HeroPension />;

View File

@ -261,7 +261,7 @@ function PlaceList({
{(Number.isFinite(distanceOf(open)) || open.distanceText) && ( {(Number.isFinite(distanceOf(open)) || open.distanceText) && (
<p className="text-[length:var(--fs-sm)] font-semibold opacity-100"> <p className="text-[length:var(--fs-sm)] font-semibold opacity-100">
{walkText(distanceOf(open)) ?? open.distanceText} {walkText(distanceOf(open)) ?? open.distanceText}
{open.distanceText && <span className="ml-1 font-normal opacity-70">{open.distanceText}</span>} {open.distanceText && <span className="ml-1 font-normal opacity-85">{open.distanceText}</span>}
</p> </p>
)} )}
{open.location && ( {open.location && (

View File

@ -60,7 +60,7 @@ export function MobileTabBar() {
{phone && ( {phone && (
<a <a
href={`tel:${phone}`} href={`tel:${phone}`}
className="tap border-line flex flex-1 items-center justify-center gap-1.5 rounded-lg border text-[length:var(--fs-sm)] font-bold" className="tap border-line flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-2 text-[length:var(--fs-sm)] font-bold"
> >
<Phone className="size-4" /> <Phone className="size-4" />
<span></span> <span></span>
@ -72,7 +72,7 @@ export function MobileTabBar() {
<button <button
type="button" type="button"
onClick={() => setBookingOpen(true)} onClick={() => setBookingOpen(true)}
className="tap flex min-w-0 flex-[1.4] items-center justify-center truncate rounded-lg px-2 text-[length:var(--fs-sm)] font-bold" className="tap flex min-w-0 flex-1 items-center justify-center truncate rounded-lg border border-transparent px-2 text-[length:var(--fs-sm)] font-bold"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
@ -82,7 +82,7 @@ export function MobileTabBar() {
href={booking.url} href={booking.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="tap flex min-w-0 flex-[1.4] items-center justify-center truncate rounded-lg px-2 text-[length:var(--fs-sm)] font-bold" className="tap flex min-w-0 flex-1 items-center justify-center truncate rounded-lg border border-transparent px-2 text-[length:var(--fs-sm)] font-bold"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
{/* channelLabel() (", ") {/* channelLabel() (", ")

View File

@ -6,9 +6,9 @@ export function SocialPostsSection() {
if (!posts.length) return null; if (!posts.length) return null;
return <section id="social-posts" className="mx-auto w-full max-w-5xl px-6 py-12"> return <section id="social-posts" className="mx-auto w-full max-w-5xl px-6 py-12">
<h2 className="mb-3 text-2xl font-semibold"> </h2> <h2 className="mb-3 text-2xl font-semibold"> </h2>
<p className="mb-6 text-sm opacity-70"> Threads에 .</p> <p className="mb-6 text-sm opacity-85"> Threads에 .</p>
<div className="grid gap-4 md:grid-cols-3">{posts.map((post) => <article key={post.postId} className="rounded-xl border border-current/15 p-5"> <div className="grid gap-4 md:grid-cols-3">{posts.map((post) => <article key={post.postId} className="rounded-xl border border-current/15 p-5">
<time dateTime={post.postedAt} className="text-sm opacity-70">{new Date(post.postedAt).toLocaleDateString('sv-SE', {timeZone: 'Asia/Seoul'})} ()</time> <time dateTime={post.postedAt} className="text-sm opacity-85">{new Date(post.postedAt).toLocaleDateString('sv-SE', {timeZone: 'Asia/Seoul'})} ()</time>
<p className="my-4 whitespace-pre-wrap break-words">{post.body}</p> <p className="my-4 whitespace-pre-wrap break-words">{post.body}</p>
{post.permalink && <a href={post.permalink} target="_blank" rel="noopener noreferrer" className="text-sm underline underline-offset-4">SNS에 </a>} {post.permalink && <a href={post.permalink} target="_blank" rel="noopener noreferrer" className="text-sm underline underline-offset-4">SNS에 </a>}
</article>)}</div> </article>)}</div>

View File

@ -1,6 +1,6 @@
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {BookingRequestSection} from './BookingRequestSection'; import {BookingRequestSection} from './BookingRequestSection';
import {CalendarDays, Check, ChevronLeft, ChevronRight, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react'; import {Check, ChevronLeft, ChevronRight, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react';
import {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared'; import {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {unitBaseRate} from '@site/seo/jsonld'; import {unitBaseRate} from '@site/seo/jsonld';
@ -155,10 +155,6 @@ export function StayBookingDemo() {
const [unitId, setUnitId] = useState<string | null>(units[0]?.unitId ?? null); const [unitId, setUnitId] = useState<string | null>(units[0]?.unitId ?? null);
const [guests, setGuests] = useState(2); const [guests, setGuests] = useState(2);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
/* . 3.5 ,
. */
const [pickerOpen, setPickerOpen] = useState(false);
const selectedDay = cells.find((cell): cell is DayCell => cell !== null && cell.iso === dateIso) ?? null; const selectedDay = cells.find((cell): cell is DayCell => cell !== null && cell.iso === dateIso) ?? null;
const selectedUnit = units.find((unit) => unit.unitId === unitId) ?? null; const selectedUnit = units.find((unit) => unit.unitId === unitId) ?? null;
const maxGuests = selectedUnit?.maxCapacity ?? 8; const maxGuests = selectedUnit?.maxCapacity ?? 8;
@ -181,24 +177,13 @@ export function StayBookingDemo() {
className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6" className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6"
style={{backgroundColor: 'var(--color-surface)'}} style={{backgroundColor: 'var(--color-surface)'}}
> >
<button
type="button"
onClick={() => setPickerOpen((value) => !value)}
aria-expanded={pickerOpen}
className="tap flex w-full items-center gap-2 border-b border-black/8 px-4 text-[length:var(--fs-sm)] font-bold sm:px-5"
>
<CalendarDays className="size-4 opacity-100" />
<span className="flex-1 text-left"> · </span>
<span className="opacity-100">{pickerOpen ? '' : '+'}</span>
</button>
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */} {/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
{today === null || cursor === null ? ( {today === null || cursor === null ? (
<p className="px-4 py-6 text-[length:var(--fs-xs)] leading-relaxed opacity-100 sm:px-5"> <p className="px-4 py-6 text-[length:var(--fs-xs)] leading-relaxed opacity-100 sm:px-5">
. .
. .
</p> </p>
) : !pickerOpen && !submitted ? null : submitted ? ( ) : submitted ? (
<ConfirmPanel <ConfirmPanel
payload={payload} payload={payload}
onReset={() => setSubmitted(false)} onReset={() => setSubmitted(false)}

View File

@ -13,6 +13,7 @@ import {Rooms as OasiRooms} from '@site/layouts/oasi/Rooms';
import {Rooms as StudioRooms} from '@site/layouts/studio/Rooms'; import {Rooms as StudioRooms} from '@site/layouts/studio/Rooms';
import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms'; import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms';
import {Rooms as EditorialRooms} from '@site/layouts/editorial/Rooms'; import {Rooms as EditorialRooms} from '@site/layouts/editorial/Rooms';
import {Rooms as PaperRooms} from '@site/layouts/paper/Rooms';
/** /**
* · · . * · · .
@ -41,6 +42,7 @@ export function UnitsSection() {
if (layout === 'studio') return <StudioRooms />; if (layout === 'studio') return <StudioRooms />;
if (layout === 'pastel') return <PastelRooms />; if (layout === 'pastel') return <PastelRooms />;
if (layout === 'editorial') return <EditorialRooms />; if (layout === 'editorial') return <EditorialRooms />;
if (layout === 'paper') return <PaperRooms />;
const roomsVariant = payload.theme.sections.find((s) => s.id === spec.path)?.variantId; const roomsVariant = payload.theme.sections.find((s) => s.id === spec.path)?.variantId;
if (roomsVariant === 'rooms.bands') return <UnitsBands />; if (roomsVariant === 'rooms.bands') return <UnitsBands />;

View File

@ -107,10 +107,10 @@ export function WeatherSection() {
{bandLine && ( {bandLine && (
<p <p
key={bandLine} key={bandLine}
className="w4-note-fade measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-100" className="w4-note-fade measure flex items-center gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-100"
> >
<span <span
className="border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-sm)] font-bold" className="border-line shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-sm)] font-bold"
aria-hidden aria-hidden
> >
{band} {band}