From 46b74b310b6127e46e94b5c81a720335d3ccc7a2 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 28 Sep 2026 15:53:02 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[feat]=20solution/backend,frontend:=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=20=EC=A0=84=EC=9A=A9=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EA=B4=80=EB=A6=AC=C2=B7=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=E2=80=94=20=EC=A1=B0=ED=9A=8C=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EA=B2=BD=EB=9F=89=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin 앱을 새 메뉴로 키우려면 새 도메인이 필요해서, solution 앱에 DEVELOPER 게이트 하나로 얹었다. 메뉴 문자열은 사장님 번들에도 실리지만 데이터 접근은 백엔드 게이트가 막는다. - router/v1/ops: GET /v1/ops/sites · /v1/ops/users (RequireDeveloper) - services/ops_service.py, crud/site_crud.list_all_sites, crud/user_crud.list_users - frontend: OpsSitesPage · OpsUsersPage, AppShell 에 role===DEVELOPER 일 때만 두 줄 - api/generated: orval 코드젠 결과 프론트 tsc·eslint 통과, openapi 코드젠 성공. DB 조회는 미검증(DEVLOG 2026-09-23) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/DEVLOG.md | 21 ++ solution/backend/crud/site_crud.py | 37 ++- solution/backend/crud/user_crud.py | 53 ++- solution/backend/router/router.py | 2 + solution/backend/router/v1/ops/__init__.py | 0 solution/backend/router/v1/ops/ops.py | 27 ++ solution/backend/router/v1/ops/protocol.py | 53 +++ solution/backend/services/ops_service.py | 89 +++++ .../frontend/src/api/generated/model/index.ts | 22 ++ .../api/generated/model/listSitesParams.ts | 18 + .../api/generated/model/listUsersParams.ts | 22 ++ .../src/api/generated/model/opsSiteData.ts | 38 +++ .../generated/model/opsSiteDataCreatedAt.ts | 8 + .../api/generated/model/opsSiteDataDomain.ts | 8 + .../generated/model/opsSiteDataOwnerEmail.ts | 8 + .../generated/model/opsSiteDataOwnerName.ts | 8 + .../generated/model/opsSiteDataPublishedAt.ts | 8 + .../api/generated/model/opsSiteDataSiteId.ts | 8 + .../api/generated/model/opsSiteDataStatus.ts | 9 + .../model/opsSiteDataThumbnailUrl.ts | 8 + .../src/api/generated/model/opsUserData.ts | 31 ++ .../model/opsUserDataContactNumber.ts | 8 + .../generated/model/opsUserDataCreatedAt.ts | 8 + .../api/generated/model/opsUserDataEmail.ts | 8 + .../model/opsUserDataLastAccessedAt.ts | 8 + .../api/generated/model/opsUserDataName.ts | 8 + .../src/api/generated/model/resOpsSites.ts | 18 + .../src/api/generated/model/resOpsSitesMsg.ts | 8 + .../src/api/generated/model/resOpsUsers.ts | 18 + .../src/api/generated/model/resOpsUsersMsg.ts | 8 + .../src/api/generated/model/userStatus.ts | 17 + .../frontend/src/api/generated/ops/ops.ts | 314 ++++++++++++++++++ solution/frontend/src/api/index.ts | 1 + .../src/components/layout/AppShell.tsx | 19 +- solution/frontend/src/pages/OpsSitesPage.tsx | 158 +++++++++ solution/frontend/src/pages/OpsUsersPage.tsx | 162 +++++++++ solution/frontend/src/routes.ts | 2 + 37 files changed, 1237 insertions(+), 6 deletions(-) create mode 100644 solution/backend/router/v1/ops/__init__.py create mode 100644 solution/backend/router/v1/ops/ops.py create mode 100644 solution/backend/router/v1/ops/protocol.py create mode 100644 solution/backend/services/ops_service.py create mode 100644 solution/frontend/src/api/generated/model/listSitesParams.ts create mode 100644 solution/frontend/src/api/generated/model/listUsersParams.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteData.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataCreatedAt.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataDomain.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataOwnerEmail.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataOwnerName.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataPublishedAt.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataSiteId.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataStatus.ts create mode 100644 solution/frontend/src/api/generated/model/opsSiteDataThumbnailUrl.ts create mode 100644 solution/frontend/src/api/generated/model/opsUserData.ts create mode 100644 solution/frontend/src/api/generated/model/opsUserDataContactNumber.ts create mode 100644 solution/frontend/src/api/generated/model/opsUserDataCreatedAt.ts create mode 100644 solution/frontend/src/api/generated/model/opsUserDataEmail.ts create mode 100644 solution/frontend/src/api/generated/model/opsUserDataLastAccessedAt.ts create mode 100644 solution/frontend/src/api/generated/model/opsUserDataName.ts create mode 100644 solution/frontend/src/api/generated/model/resOpsSites.ts create mode 100644 solution/frontend/src/api/generated/model/resOpsSitesMsg.ts create mode 100644 solution/frontend/src/api/generated/model/resOpsUsers.ts create mode 100644 solution/frontend/src/api/generated/model/resOpsUsersMsg.ts create mode 100644 solution/frontend/src/api/generated/model/userStatus.ts create mode 100644 solution/frontend/src/api/generated/ops/ops.ts create mode 100644 solution/frontend/src/pages/OpsSitesPage.tsx create mode 100644 solution/frontend/src/pages/OpsUsersPage.tsx diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 65ea3f5..0aa391d 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,26 @@ # 개발 일지 +## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로 + +admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안 +갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트 +하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다. + +- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부 + `RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` / + `crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다 + (`UserRole` 주석 원칙을 내부 화면에도 지킨다). +- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`). + `AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다. + ★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐, + 빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다. + 실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다. +- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만. + +**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트· +스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` · +`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요. + ## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다 실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index c76df31..c2fe5fe 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites +from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites, users from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus from common.logger import LOG from common.utils.gtime import GTime @@ -48,6 +48,12 @@ class ISiteCRUD(ABC): """(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).""" pass + @abstractmethod + async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]: + """전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용. + (ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수).""" + pass + @abstractmethod async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: pass @@ -157,6 +163,35 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 + async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]: + """list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다. + (ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수).""" + try: + where = places.deleted == False # noqa: E712 + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where)) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + query = ( + select(places, sites, site_versions.built_at, _primary_photo_subquery(), users.id, users.email, users.name) + .join(users, users.user_id == places.owner_user_id) + .outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712 + .outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id) + .where(where) + .order_by(places.created_at.desc()) + .offset(skip) + .limit(limit) + ) + list_err, rows = await DB_SESSION_MNG.execute(cdb, query) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" try: diff --git a/solution/backend/crud/user_crud.py b/solution/backend/crud/user_crud.py index d24088c..be53a47 100644 --- a/solution/backend/crud/user_crud.py +++ b/solution/backend/crud/user_crud.py @@ -1,16 +1,27 @@ from abc import ABC, abstractmethod from typing import Tuple -from sqlalchemy import select, func, update +from sqlalchemy import and_, select, func, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import users +from common.database.model.models import places, users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime +def _place_count_subquery(): + """계정 하나가 가진 사업장 수 — users 에 상관 서브쿼리로 얹는다(N+1 회피).""" + return ( + select(func.count()) + .select_from(places) + .where(places.owner_user_id == users.user_id, places.deleted == False) # noqa: E712 + .correlate(users) + .scalar_subquery() + ) + + # CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다. # - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이). # - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다. @@ -47,6 +58,13 @@ class IUserCRUD(ABC): async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType: pass + @abstractmethod + async def list_users( + self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None + ) -> Tuple[ErrorType, list, int]: + """내부 운영(DEVELOPER) 전용 전체 계정 목록. (ErrorType, [(user, place_count)], 총건수).""" + pass + class UserCRUD(IUserCRUD): async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: @@ -151,3 +169,34 @@ class UserCRUD(IUserCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def list_users( + self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None + ) -> Tuple[ErrorType, list, int]: + """role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다 + (UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다).""" + try: + where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712 + if search: + like = f"%{search.strip()}%" + where = and_(where, (users.email.ilike(like) | users.name.ilike(like) | users.id.ilike(like))) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where)) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + query = ( + select(users, _place_count_subquery()) + .where(where) + .order_by(users.created_at.desc()) + .offset(skip) + .limit(limit) + ) + list_err, rows = await DB_SESSION_MNG.execute(cdb, query) + if list_err != ErrorType.SUCCESS: + return list_err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index cf46e52..bb3a81c 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -25,6 +25,7 @@ import router.v1.site.booking_request import router.v1.site.post import router.v1.site.review import router.v1.local.local +import router.v1.ops.ops import router.v1.social.social import router.v1.social.oauth import router.v1.agent.kakao @@ -138,6 +139,7 @@ app.include_router(router.v1.site.post.owner_router) app.include_router(router.v1.site.review.router) app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.weather_router) +app.include_router(router.v1.ops.ops.router) app.include_router(router.v1.social.social.router) app.include_router(router.v1.social.oauth.router) diff --git a/solution/backend/router/v1/ops/__init__.py b/solution/backend/router/v1/ops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/backend/router/v1/ops/ops.py b/solution/backend/router/v1/ops/ops.py new file mode 100644 index 0000000..16031fa --- /dev/null +++ b/solution/backend/router/v1/ops/ops.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends, Query + +from common.models.gmodel import PageParams +from router.v1.validator.dependencies import RemoveNoneResponse, RequireDeveloper +from services.ops_service import OpsService +from .protocol import Res_OpsSites, Res_OpsUsers + +# 내부 운영(개발자) 전용 조회 — 회사 스코프를 걷어낸 전 계정 사이트·유저 목록. +# admin/frontend(:9801)를 새 도메인으로 키우는 대신 solution 앱(:9800)에 경량으로 얹은 것이다 +# (2026-09-23 기획). RequireDeveloper 가 유일한 문이다 — OWNER(사장님)는 관리할 하위 계정이 +# 없어(2026-09-08 회사/테넌트 걷어냄) 이 화면을 볼 이유가 없다. +router = APIRouter(prefix="/v1/ops", tags=["Ops"]) + + +@router.get(path="/sites", response_model=Res_OpsSites, summary="전 계정 사이트 목록(개발자 전용)") +async def list_sites(service: OpsService = Depends(), pg: PageParams = Depends(), _user=Depends(RequireDeveloper)): + return RemoveNoneResponse(await service.list_sites(pg)) + + +@router.get(path="/users", response_model=Res_OpsUsers, summary="전 계정 목록(개발자 전용)") +async def list_users( + service: OpsService = Depends(), + pg: PageParams = Depends(), + search: str | None = Query(None, description="이메일·이름·로그인 아이디 부분 일치"), + _user=Depends(RequireDeveloper), +): + return RemoveNoneResponse(await service.list_users(pg, search)) diff --git a/solution/backend/router/v1/ops/protocol.py b/solution/backend/router/v1/ops/protocol.py new file mode 100644 index 0000000..361f8d0 --- /dev/null +++ b/solution/backend/router/v1/ops/protocol.py @@ -0,0 +1,53 @@ +import uuid +from datetime import datetime +from typing import Optional + +from common.enums import AuthProvider, PlaceCategory, PlaceStatus, SiteStatus, UserRole, UserStatus +from common.models.gmodel import Res_PageProtocol, WebPacketProtocol + + +class OpsSiteData(WebPacketProtocol): + """전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자. + + ★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다.""" + + place_id: uuid.UUID + name: str + category: PlaceCategory + place_status: PlaceStatus + created_at: Optional[datetime] = None + + site_id: Optional[uuid.UUID] = None + status: Optional[SiteStatus] = None + domain: Optional[str] = None + published_at: Optional[datetime] = None + thumbnail_url: Optional[str] = None + needs_rebuild: bool = False + + owner_login_id: str + owner_email: Optional[str] = None + owner_name: Optional[str] = None + + +class Res_OpsSites(Res_PageProtocol): + sites: list[OpsSiteData] = [] + + +class OpsUserData(WebPacketProtocol): + """전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다.""" + + user_id: uuid.UUID + login_id: str + name: Optional[str] = None + email: Optional[str] = None + contact_number: Optional[str] = None + role: UserRole + status: UserStatus + provider: AuthProvider + created_at: Optional[datetime] = None + last_accessed_at: Optional[datetime] = None + place_count: int = 0 + + +class Res_OpsUsers(Res_PageProtocol): + users: list[OpsUserData] = [] diff --git a/solution/backend/services/ops_service.py b/solution/backend/services/ops_service.py new file mode 100644 index 0000000..3ea5fa0 --- /dev/null +++ b/solution/backend/services/ops_service.py @@ -0,0 +1,89 @@ +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import places, users +from common.enums import DBWRType, ErrorType, UserRole +from common.models.gmodel import PageParams +from crud.site_crud import ISiteCRUD, SiteCRUD +from crud.user_crud import IUserCRUD, UserCRUD +from router.v1.ops.protocol import OpsSiteData, OpsUserData, Res_OpsSites, Res_OpsUsers + + +class OpsService: + """내부 운영(개발자) 전용 조회 — 회사 스코프 없는 전 계정 사이트·유저 목록. + + admin/frontend 의 사업장 화면(사실 검증)과 역할이 다르다 — 여기는 발행 인프라 상태와 + 계정 상태만 본다. 게이트는 라우터가 RequireDeveloper 로 건다(여기서는 다시 확인하지 않는다).""" + + def __init__(self, site_crud: ISiteCRUD = Depends(SiteCRUD), user_crud: IUserCRUD = Depends(UserCRUD)): + self.site_crud = site_crud + self.user_crud = user_crud + + async def list_sites(self, pg: PageParams) -> Res_OpsSites: + res = Res_OpsSites(page=pg.page, size=pg.size) + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + places.DBType(), DBWRType.DB_READ.value, + lambda s: self.site_crud.list_all_sites(s, pg.skip, pg.size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.sites = [ + self._site_row(place, site, built_at, photo, login_id, email, name) + for place, site, built_at, photo, login_id, email, name in rows + ] + res.total = total + return res + + @staticmethod + def _site_row(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name) -> OpsSiteData: + # ★ 재빌드 판별은 site_service._my_site_row 와 같은 규칙이어야 한다 — 다르면 화면마다 다른 답을 한다. + changed = place.content_updated_at + ever_published = site is not None and getattr(site, "published_at", None) is not None + thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None) + return OpsSiteData( + place_id=place.place_id, + name=place.name, + category=place.category, + place_status=place.status, + created_at=place.created_at, + site_id=getattr(site, "site_id", None), + status=getattr(site, "status", None), + domain=getattr(site, "domain", None), + published_at=getattr(site, "published_at", None), + thumbnail_url=thumbnail_url, + needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), + owner_login_id=owner_login_id, + owner_email=owner_email, + owner_name=owner_name, + ) + + async def list_users(self, pg: PageParams, search: str | None = None) -> Res_OpsUsers: + """USER/OWNER 만 — 개발자 계정은 목록에서 뺀다(UserRole 주석: 고객사에 존재를 노출하지 않는다).""" + res = Res_OpsUsers(page=pg.page, size=pg.size) + roles = [UserRole.USER.value, UserRole.OWNER.value] + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + users.DBType(), DBWRType.DB_READ.value, + lambda s: self.user_crud.list_users(s, roles, pg.skip, pg.size, search), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.users = [ + OpsUserData( + user_id=user.user_id, + login_id=user.id, + name=user.name, + email=user.email, + contact_number=user.contact_number, + role=user.role, + status=user.status, + provider=user.provider, + created_at=user.created_at, + last_accessed_at=user.last_accessed_at, + place_count=place_count, + ) + for user, place_count in rows + ] + res.total = total + return res diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index 273a6d6..a0f6ce3 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -74,7 +74,9 @@ export * from "./listMySitesParams"; export * from "./listPlacesParams"; export * from "./listReviewsParams"; export * from "./listShowcaseParams"; +export * from "./listSitesParams"; export * from "./listUpcomingPostsParams"; +export * from "./listUsersParams"; export * from "./localContentData"; export * from "./localContentDataBody"; export * from "./localContentDataCollectedAt"; @@ -107,6 +109,21 @@ export * from "./mySiteDataStatus"; export * from "./mySiteDataTemplateId"; export * from "./mySiteDataThumbnailUrl"; export * from "./oauthCallbackParams"; +export * from "./opsSiteData"; +export * from "./opsSiteDataCreatedAt"; +export * from "./opsSiteDataDomain"; +export * from "./opsSiteDataOwnerEmail"; +export * from "./opsSiteDataOwnerName"; +export * from "./opsSiteDataPublishedAt"; +export * from "./opsSiteDataSiteId"; +export * from "./opsSiteDataStatus"; +export * from "./opsSiteDataThumbnailUrl"; +export * from "./opsUserData"; +export * from "./opsUserDataContactNumber"; +export * from "./opsUserDataCreatedAt"; +export * from "./opsUserDataEmail"; +export * from "./opsUserDataLastAccessedAt"; +export * from "./opsUserDataName"; export * from "./placeCandidate"; export * from "./placeCandidateAddress"; export * from "./placeCandidateCategoryName"; @@ -291,6 +308,10 @@ export * from "./resMyPosts"; export * from "./resMyPostsMsg"; export * from "./resMySites"; export * from "./resMySitesMsg"; +export * from "./resOpsSites"; +export * from "./resOpsSitesMsg"; +export * from "./resOpsUsers"; +export * from "./resOpsUsersMsg"; export * from "./resPlace"; export * from "./resPlaceContentList"; export * from "./resPlaceContentListMsg"; @@ -381,6 +402,7 @@ export * from "./sourceType"; export * from "./testPost"; export * from "./unitData"; export * from "./userRole"; +export * from "./userStatus"; export * from "./validationError"; export * from "./validationErrorCtx"; export * from "./validationErrorLocItem"; diff --git a/solution/frontend/src/api/generated/model/listSitesParams.ts b/solution/frontend/src/api/generated/model/listSitesParams.ts new file mode 100644 index 0000000..536f6cc --- /dev/null +++ b/solution/frontend/src/api/generated/model/listSitesParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ListSitesParams = { + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + size?: number; +}; diff --git a/solution/frontend/src/api/generated/model/listUsersParams.ts b/solution/frontend/src/api/generated/model/listUsersParams.ts new file mode 100644 index 0000000..ac99ff3 --- /dev/null +++ b/solution/frontend/src/api/generated/model/listUsersParams.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ListUsersParams = { + /** + * 이메일·이름·로그인 아이디 부분 일치 + */ + search?: string | null; + /** + * @minimum 1 + */ + page?: number; + /** + * @minimum 1 + * @maximum 100 + */ + size?: number; +}; diff --git a/solution/frontend/src/api/generated/model/opsSiteData.ts b/solution/frontend/src/api/generated/model/opsSiteData.ts new file mode 100644 index 0000000..b1abc59 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteData.ts @@ -0,0 +1,38 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { PlaceCategory } from "./placeCategory"; +import type { PlaceStatus } from "./placeStatus"; +import type { OpsSiteDataCreatedAt } from "./opsSiteDataCreatedAt"; +import type { OpsSiteDataSiteId } from "./opsSiteDataSiteId"; +import type { OpsSiteDataStatus } from "./opsSiteDataStatus"; +import type { OpsSiteDataDomain } from "./opsSiteDataDomain"; +import type { OpsSiteDataPublishedAt } from "./opsSiteDataPublishedAt"; +import type { OpsSiteDataThumbnailUrl } from "./opsSiteDataThumbnailUrl"; +import type { OpsSiteDataOwnerEmail } from "./opsSiteDataOwnerEmail"; +import type { OpsSiteDataOwnerName } from "./opsSiteDataOwnerName"; + +/** + * 전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자. + +★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다. + */ +export interface OpsSiteData { + place_id: string; + name: string; + category: PlaceCategory; + place_status: PlaceStatus; + created_at?: OpsSiteDataCreatedAt; + site_id?: OpsSiteDataSiteId; + status?: OpsSiteDataStatus; + domain?: OpsSiteDataDomain; + published_at?: OpsSiteDataPublishedAt; + thumbnail_url?: OpsSiteDataThumbnailUrl; + needs_rebuild?: boolean; + owner_login_id: string; + owner_email?: OpsSiteDataOwnerEmail; + owner_name?: OpsSiteDataOwnerName; +} diff --git a/solution/frontend/src/api/generated/model/opsSiteDataCreatedAt.ts b/solution/frontend/src/api/generated/model/opsSiteDataCreatedAt.ts new file mode 100644 index 0000000..2527540 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataCreatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataCreatedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataDomain.ts b/solution/frontend/src/api/generated/model/opsSiteDataDomain.ts new file mode 100644 index 0000000..c78ab42 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataDomain.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataDomain = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataOwnerEmail.ts b/solution/frontend/src/api/generated/model/opsSiteDataOwnerEmail.ts new file mode 100644 index 0000000..5e21485 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataOwnerEmail.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataOwnerEmail = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataOwnerName.ts b/solution/frontend/src/api/generated/model/opsSiteDataOwnerName.ts new file mode 100644 index 0000000..5fb8ab9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataOwnerName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataOwnerName = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataPublishedAt.ts b/solution/frontend/src/api/generated/model/opsSiteDataPublishedAt.ts new file mode 100644 index 0000000..0bb9a55 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataPublishedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataPublishedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataSiteId.ts b/solution/frontend/src/api/generated/model/opsSiteDataSiteId.ts new file mode 100644 index 0000000..aea1a00 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataSiteId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataSiteId = string | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataStatus.ts b/solution/frontend/src/api/generated/model/opsSiteDataStatus.ts new file mode 100644 index 0000000..a3dbe92 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataStatus.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { SiteStatus } from "./siteStatus"; + +export type OpsSiteDataStatus = SiteStatus | null; diff --git a/solution/frontend/src/api/generated/model/opsSiteDataThumbnailUrl.ts b/solution/frontend/src/api/generated/model/opsSiteDataThumbnailUrl.ts new file mode 100644 index 0000000..0cd0cbf --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsSiteDataThumbnailUrl.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsSiteDataThumbnailUrl = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserData.ts b/solution/frontend/src/api/generated/model/opsUserData.ts new file mode 100644 index 0000000..fd490c8 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserData.ts @@ -0,0 +1,31 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { OpsUserDataName } from "./opsUserDataName"; +import type { OpsUserDataEmail } from "./opsUserDataEmail"; +import type { OpsUserDataContactNumber } from "./opsUserDataContactNumber"; +import type { UserRole } from "./userRole"; +import type { UserStatus } from "./userStatus"; +import type { AuthProvider } from "./authProvider"; +import type { OpsUserDataCreatedAt } from "./opsUserDataCreatedAt"; +import type { OpsUserDataLastAccessedAt } from "./opsUserDataLastAccessedAt"; + +/** + * 전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다. + */ +export interface OpsUserData { + user_id: string; + login_id: string; + name?: OpsUserDataName; + email?: OpsUserDataEmail; + contact_number?: OpsUserDataContactNumber; + role: UserRole; + status: UserStatus; + provider: AuthProvider; + created_at?: OpsUserDataCreatedAt; + last_accessed_at?: OpsUserDataLastAccessedAt; + place_count?: number; +} diff --git a/solution/frontend/src/api/generated/model/opsUserDataContactNumber.ts b/solution/frontend/src/api/generated/model/opsUserDataContactNumber.ts new file mode 100644 index 0000000..5147e11 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataContactNumber.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataContactNumber = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataCreatedAt.ts b/solution/frontend/src/api/generated/model/opsUserDataCreatedAt.ts new file mode 100644 index 0000000..c64c9cf --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataCreatedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataCreatedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataEmail.ts b/solution/frontend/src/api/generated/model/opsUserDataEmail.ts new file mode 100644 index 0000000..09ab82f --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataEmail.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataEmail = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataLastAccessedAt.ts b/solution/frontend/src/api/generated/model/opsUserDataLastAccessedAt.ts new file mode 100644 index 0000000..465b1a6 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataLastAccessedAt.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataLastAccessedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/opsUserDataName.ts b/solution/frontend/src/api/generated/model/opsUserDataName.ts new file mode 100644 index 0000000..ffc9740 --- /dev/null +++ b/solution/frontend/src/api/generated/model/opsUserDataName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type OpsUserDataName = string | null; diff --git a/solution/frontend/src/api/generated/model/resOpsSites.ts b/solution/frontend/src/api/generated/model/resOpsSites.ts new file mode 100644 index 0000000..e8e11e2 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsSites.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResOpsSitesMsg } from "./resOpsSitesMsg"; +import type { OpsSiteData } from "./opsSiteData"; + +export interface ResOpsSites { + result?: ErrorInfo; + msg?: ResOpsSitesMsg; + total?: number; + page?: number; + size?: number; + sites?: OpsSiteData[]; +} diff --git a/solution/frontend/src/api/generated/model/resOpsSitesMsg.ts b/solution/frontend/src/api/generated/model/resOpsSitesMsg.ts new file mode 100644 index 0000000..4c4d3f8 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsSitesMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResOpsSitesMsg = string | null; diff --git a/solution/frontend/src/api/generated/model/resOpsUsers.ts b/solution/frontend/src/api/generated/model/resOpsUsers.ts new file mode 100644 index 0000000..1251e6d --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsUsers.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from "./errorInfo"; +import type { ResOpsUsersMsg } from "./resOpsUsersMsg"; +import type { OpsUserData } from "./opsUserData"; + +export interface ResOpsUsers { + result?: ErrorInfo; + msg?: ResOpsUsersMsg; + total?: number; + page?: number; + size?: number; + users?: OpsUserData[]; +} diff --git a/solution/frontend/src/api/generated/model/resOpsUsersMsg.ts b/solution/frontend/src/api/generated/model/resOpsUsersMsg.ts new file mode 100644 index 0000000..35586a8 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resOpsUsersMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type ResOpsUsersMsg = string | null; diff --git a/solution/frontend/src/api/generated/model/userStatus.ts b/solution/frontend/src/api/generated/model/userStatus.ts new file mode 100644 index 0000000..909aed9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/userStatus.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +/** + * users.status 코드값. + */ +export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const UserStatus = { + ACTIVE: 1, + INACTIVE: 2, +} as const; diff --git a/solution/frontend/src/api/generated/ops/ops.ts b/solution/frontend/src/api/generated/ops/ops.ts new file mode 100644 index 0000000..e2822c4 --- /dev/null +++ b/solution/frontend/src/api/generated/ops/ops.ts @@ -0,0 +1,314 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + HTTPValidationError, + ListSitesParams, + ListUsersParams, + ResOpsSites, + ResOpsUsers, +} from ".././model"; + +import { customFetch } from "../../mutator/custom-fetch"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * @summary 전 계정 사이트 목록(개발자 전용) + */ +export const listSites = ( + params?: ListSitesParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/ops/sites`, method: "GET", params, signal }, + options, + ); +}; + +export const getListSitesQueryKey = (params?: ListSitesParams) => { + return [`/v1/ops/sites`, ...(params ? [params] : [])] as const; +}; + +export const getListSitesQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListSitesQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listSites(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListSitesQueryResult = NonNullable< + Awaited> +>; +export type ListSitesQueryError = HTTPValidationError; + +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | ListSitesParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 전 계정 사이트 목록(개발자 전용) + */ + +export function useListSites< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListSitesParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListSitesQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * @summary 전 계정 목록(개발자 전용) + */ +export const listUsers = ( + params?: ListUsersParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customFetch( + { url: `/v1/ops/users`, method: "GET", params, signal }, + options, + ); +}; + +export const getListUsersQueryKey = (params?: ListUsersParams) => { + return [`/v1/ops/users`, ...(params ? [params] : [])] as const; +}; + +export const getListUsersQueryOptions = < + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listUsers(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListUsersQueryResult = NonNullable< + Awaited> +>; +export type ListUsersQueryError = HTTPValidationError; + +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params: undefined | ListUsersParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 전 계정 목록(개발자 전용) + */ + +export function useListUsers< + TData = Awaited>, + TError = HTTPValidationError, +>( + params?: ListUsersParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getListUsersQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} diff --git a/solution/frontend/src/api/index.ts b/solution/frontend/src/api/index.ts index 22f4cd8..312fc24 100644 --- a/solution/frontend/src/api/index.ts +++ b/solution/frontend/src/api/index.ts @@ -19,6 +19,7 @@ export * from './generated/job/job'; export * from './generated/site/site'; export * from './generated/media/media'; export * from './generated/faq/faq'; +export * from './generated/ops/ops'; export * from './generated/model'; // 잡 폴링(수집·비전·생성·빌드 공용). 생성물이 아니라 그 위에 얹은 얇은 유틸이다. diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index da25315..f999814 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,7 @@ import type {ComponentType, ReactNode} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router'; -import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react'; +import {Globe2, LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Users, Wand2} from 'lucide-react'; +import {UserRole} from '@/api'; import {cn} from '@/lib/utils'; import {userLabel, useAuthStore} from '@/stores/auth'; @@ -36,11 +37,23 @@ const OWNER_NAV: NavItem[] = [ {to: '/pricing', match: '/pricing', label: '요금', icon: Receipt}, ]; -export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) { +/** + * 개발자 전용 두 줄(사이트관리·유저관리, 2026-09-23 기획). admin/frontend 를 새 도메인으로 + * 키우는 대신 solution 앱에 경량으로 얹은 것 — 그래서 위 주석의 "메뉴가 섞이면 새어 나간다"가 + * 그대로 적용된다: 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임에만 + * 숨긴다). 실제 데이터 접근은 백엔드 RequireDeveloper 가 막으므로 노출은 경로 이름 정도다. + */ +const DEVELOPER_NAV: NavItem[] = [ + {to: '/ops/sites', match: '/ops/sites', label: '사이트관리', icon: Globe2}, + {to: '/ops/users', match: '/ops/users', label: '유저관리', icon: Users}, +]; + +export function AppShell({children, nav}: {children: ReactNode; nav?: NavItem[]}) { const user = useAuthStore((s) => s.user); const signOut = useAuthStore((s) => s.signOut); const location = useLocation(); const navigate = useNavigate(); + const items = nav ?? (user?.role === UserRole.DEVELOPER ? [...OWNER_NAV, ...DEVELOPER_NAV] : OWNER_NAV); return (
@@ -52,7 +65,7 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
+ } + > + {isLoading && ( +
+ +
+ )} + + {isError && ( + + )} + + {!isLoading && !isError && rows.length === 0 && ( + + )} + + {!isLoading && !isError && rows.length > 0 && ( + <> +
    + {rows.map((row) => { + const statusBadge = STATUS_BADGE[row.status ?? UserStatus.ACTIVE] ?? STATUS_BADGE[UserStatus.ACTIVE]; + return ( +
  • +
    +
    +

    {row.name || row.login_id}

    + {ROLE_LABEL[row.role] ?? '알 수 없음'} + {statusBadge.label} + {PROVIDER_LABEL[row.provider] ?? '알 수 없음'} +
    +

    + {[row.email, row.contact_number].filter(Boolean).join(' · ') || row.login_id} +

    +
    + + + 사이트 {row.place_count}개 + +
  • + ); + })} +
+ +
+ 총 {total}건 +
+ + + {page} / {lastPage} + + +
+
+ + )} + + + ); +} + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default OpsUsersPage; diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts index b66c80f..972973f 100644 --- a/solution/frontend/src/routes.ts +++ b/solution/frontend/src/routes.ts @@ -29,6 +29,8 @@ export default [ // "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) — // 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다. route('blog', 'pages/BlogPostsPage.tsx'), + route('ops/sites', 'pages/OpsSitesPage.tsx'), + route('ops/users', 'pages/OpsUsersPage.tsx'), ]), /** From c6908629f3c3ea774c73a85bfd2d8c3e93e3a984 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 28 Sep 2026 15:57:35 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[feat]=20solution:=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=A0=95=EC=9D=98=EB=A5=BC=20templates.json=20?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20=E2=80=94=20=EC=97=85?= =?UTF-8?q?=EC=A2=85=20=EC=97=86=EB=8A=94=20id=20=C2=B7=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20basic/paper=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 템플릿 정보가 빌더·렌더러·백엔드에 따로 적혀 어긋나 있었다 — 백엔드 기본값이 없는 id 를 가리켰고, 음식점 강조색 오타, 섹션 간격이 빌더와 서버에서 달랐다. - shared/src/data/templates.json: 템플릿 4개(simple·magazine·retro·paper)와 업종별 허용·기본 - shared/lib/catalog.ts · backend/common/template_catalog.py: 같은 JSON 을 읽는다, 모르는 id 는 에러 - 저장·미리보기·발행에서 모르는 id 를 거절한다(site_service · site.py 422 · build_service 실패) - site: 레이아웃 등록표(basic·paper) + LayoutProvider, Shell→Frame, HomePage→SectionList - 연결 안 된 레이아웃 5개, 배치 고르기(variant), 서체 선택, 빌더 canvas/DevShowcase 삭제 - 빌더: 템플릿을 바꾸면 이전 템플릿이 켠 섹션을 끄고 안내 문구를 띄운다 - postgres-init/migrations/0023: stay-retro → retro, 병원 허용 밖은 NULL(운영 미적용) - Dockerfile·worker: 백엔드 이미지에 shared/src/data 복사 - docs: TEMPLATES.md 신설(세 폴더 역할·템플릿 추가·렌더링 순서), DATA_MODEL·ARCHITECTURE 등 갱신 shared·site·frontend tsc 통과, site vitest 통과, 백엔드 DB 없는 테스트 41 passed(DB 테스트 미실행) Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + docs/ARCHITECTURE.md | 4 +- docs/DATA_MODEL.md | 6 +- docs/DECISIONS.md | 4 +- docs/DEVLOG.md | 40 + docs/SOCIAL.md | 2 +- docs/TEMPLATES.md | 177 +++++ postgres-init/init-data/init.sql | 6 +- .../0023_template_ids_without_industry.sql | 21 + solution/backend/Dockerfile | 1 + solution/backend/Dockerfile.worker | 1 + .../backend/common/database/model/models.py | 11 +- solution/backend/common/template_catalog.py | 41 + solution/backend/router/v1/site/protocol.py | 102 +-- solution/backend/router/v1/site/site.py | 35 +- solution/backend/services/build_service.py | 111 +-- solution/backend/services/site_payload.py | 508 ++----------- solution/backend/services/site_service.py | 179 +---- solution/backend/tests/test_my_sites.py | 6 +- solution/backend/tests/test_site_template.py | 90 +-- solution/backend/tests/test_site_theme.py | 137 ++-- solution/frontend/src/data/industryData.ts | 341 +-------- .../src/features/builder/CanvasView.tsx | 19 - .../src/features/builder/ItemFormEditor.tsx | 20 +- .../src/features/builder/RightTabsPanel.tsx | 39 +- .../features/builder/SectionDesignPanel.tsx | 172 +---- .../src/features/builder/SectionListPanel.tsx | 38 +- .../src/features/builder/SitePreview.tsx | 37 +- .../src/features/builder/TemplatePreview.tsx | 9 +- .../builder/canvas/SlateScriptRenderer.tsx | 145 ---- .../src/features/builder/canvas/addable.ts | 51 -- .../builder/canvas/primitives/AddressCard.tsx | 118 --- .../builder/canvas/primitives/CtaLink.tsx | 80 -- .../canvas/primitives/EmptyStateNotice.tsx | 21 - .../builder/canvas/primitives/FeatureCard.tsx | 47 -- .../canvas/primitives/HiddenFieldsNotice.tsx | 18 - .../builder/canvas/primitives/InfoRow.tsx | 77 -- .../builder/canvas/primitives/Lightbox.tsx | 80 -- .../builder/canvas/primitives/PhotoTile.tsx | 72 -- .../builder/canvas/primitives/Pill.tsx | 38 - .../canvas/primitives/PlaceCarousel.tsx | 222 ------ .../builder/canvas/primitives/PlaceRow.tsx | 89 --- .../builder/canvas/primitives/PriceRow.tsx | 89 --- .../builder/canvas/primitives/Rail.tsx | 234 ------ .../canvas/primitives/SectionFrame.tsx | 111 --- .../canvas/primitives/SectionHeading.tsx | 126 ---- .../canvas/primitives/WalkFilterTabs.tsx | 58 -- .../builder/canvas/primitives/index.ts | 23 - .../src/features/builder/canvas/registry.ts | 519 ------------- .../src/features/builder/canvas/types.ts | 76 -- .../canvas/variants/booking/BookingBanner.tsx | 40 - .../canvas/variants/booking/BookingCard.tsx | 148 ---- .../variants/chronicle/ChronicleRail.tsx | 131 ---- .../builder/canvas/variants/common.ts | 110 --- .../canvas/variants/daily/DailyCalendar.tsx | 164 ---- .../variants/exhibition/ExhibitionGallery.tsx | 57 -- .../variants/exhibition/ExhibitionNotice.tsx | 44 -- .../canvas/variants/faq/FaqAccordion.tsx | 68 -- .../canvas/variants/faq/FaqOpenList.tsx | 50 -- .../canvas/variants/faq/FaqTwoColumn.tsx | 39 - .../builder/canvas/variants/faq/useFaqList.ts | 24 - .../canvas/variants/hero/HeroCover.tsx | 84 --- .../canvas/variants/hero/HeroSplit.tsx | 50 -- .../canvas/variants/info/InfoCards.tsx | 41 - .../canvas/variants/info/InfoInline.tsx | 41 - .../canvas/variants/info/InfoTable.tsx | 42 -- .../canvas/variants/inquiry/InquiryCta.tsx | 49 -- .../canvas/variants/inquiry/InquiryForm.tsx | 71 -- .../canvas/variants/intro/IntroCentered.tsx | 35 - .../canvas/variants/intro/IntroSideBySide.tsx | 51 -- .../canvas/variants/intro/IntroStory.tsx | 45 -- .../builder/canvas/variants/items/common.tsx | 182 ----- .../builder/canvas/variants/items/items.css | 126 ---- .../variants/itinerary/ItineraryTickets.tsx | 194 ----- .../variants/local/LocalCategorySection.tsx | 63 -- .../canvas/variants/local/LocalGuide.tsx | 93 --- .../canvas/variants/local/WeatherPanel.tsx | 126 ---- .../builder/canvas/variants/local/types.ts | 26 - .../builder/canvas/variants/local/walking.ts | 36 - .../canvas/variants/map/MapCompact.tsx | 50 -- .../canvas/variants/map/MapDetailed.tsx | 111 --- .../builder/canvas/variants/menu/MenuGrid.tsx | 31 - .../builder/canvas/variants/menu/MenuList.tsx | 29 - .../canvas/variants/menu/MenuPriceTable.tsx | 45 -- .../canvas/variants/menu/useMenuItems.ts | 13 - .../variants/people/PeopleFilmstrip.tsx | 143 ---- .../canvas/variants/photos/PhotosCarousel.tsx | 45 -- .../canvas/variants/photos/PhotosGrid.tsx | 39 - .../canvas/variants/photos/PhotosMasonry.tsx | 49 -- .../variants/photos/PhotosWithVideos.tsx | 169 ----- .../canvas/variants/photos/VideoCard.tsx | 108 --- .../variants/postcard/PostcardStack.tsx | 133 ---- .../variants/programs/ProgramsCards.tsx | 29 - .../variants/programs/ProgramsTable.tsx | 60 -- .../variants/programs/useProgramItems.ts | 13 - .../builder/canvas/variants/quiz/QuizFlip.tsx | 120 --- .../canvas/variants/reading/ReadingRail.tsx | 93 --- .../canvas/variants/rooms/RoomCard.tsx | 171 ----- .../canvas/variants/rooms/RoomsCarousel.tsx | 47 -- .../canvas/variants/rooms/RoomsGrid.tsx | 45 -- .../canvas/variants/rooms/RoomsList.tsx | 42 -- .../canvas/variants/songs/SongsTurntable.tsx | 180 ----- .../canvas/variants/space/SpaceList.tsx | 28 - .../canvas/variants/space/SpaceZones.tsx | 51 -- .../canvas/variants/video/VideoFrame.tsx | 90 --- .../variants/weather/WeatherSection.tsx | 27 - .../src/features/builder/colorPalettes.ts | 2 +- .../src/features/{dev => builder}/palette.ts | 46 +- .../src/features/builder/sections/addable.ts | 49 ++ .../builder/{canvas => sections}/dataSpec.ts | 53 +- .../builder/{canvas => sections}/thumbs.tsx | 25 +- .../src/features/dev/DragCarousel.tsx | 224 ------ .../frontend/src/features/dev/mockProps.ts | 74 -- .../frontend/src/features/dev/tokenGroups.ts | 340 --------- .../src/features/onboarding/Step4Template.tsx | 21 +- .../src/features/publish/siteTheme.ts | 91 +-- .../features/social/SocialCanvasSection.tsx | 10 - solution/frontend/src/hooks/useLocalGuide.ts | 120 --- .../frontend/src/pages/DevShowcasePage.tsx | 706 ------------------ solution/frontend/src/routes.ts | 32 +- solution/frontend/src/stores/builder.ts | 307 ++------ solution/shared/src/data/templates.json | 651 ++++++++++++++++ solution/shared/src/index.ts | 1 + solution/shared/src/lib/catalog.ts | 46 ++ solution/shared/src/types/builder.ts | 172 ++--- solution/shared/src/types/site-payload.ts | 179 +---- solution/site/src/App.tsx | 70 +- solution/site/src/app.test.tsx | 30 + solution/site/src/entry-client.tsx | 37 +- solution/site/src/fixtures/moonlight-stay.ts | 8 +- solution/site/src/layouts/basic/Frame.tsx | 13 + solution/site/src/layouts/editorial/Hero.tsx | 163 ---- solution/site/src/layouts/editorial/Rooms.tsx | 240 ------ .../src/layouts/editorial/SectionHead.tsx | 191 ----- solution/site/src/layouts/editorial/Shell.tsx | 234 ------ solution/site/src/layouts/editorial/index.ts | 14 - solution/site/src/layouts/index.ts | 34 +- solution/site/src/layouts/oasi/Hero.tsx | 109 --- solution/site/src/layouts/oasi/Rooms.tsx | 137 ---- .../site/src/layouts/oasi/SectionHead.tsx | 40 - solution/site/src/layouts/oasi/Shell.tsx | 400 ---------- solution/site/src/layouts/oasi/index.ts | 11 - .../layouts/paper/{Shell.tsx => Frame.tsx} | 2 +- solution/site/src/layouts/paper/index.ts | 4 - solution/site/src/layouts/pastel/Hero.tsx | 203 ----- solution/site/src/layouts/pastel/Rooms.tsx | 181 ----- .../site/src/layouts/pastel/SectionHead.tsx | 47 -- solution/site/src/layouts/pastel/Shell.tsx | 207 ----- solution/site/src/layouts/pastel/index.ts | 4 - .../site/src/layouts/reservation/Hero.tsx | 309 -------- .../site/src/layouts/reservation/Rooms.tsx | 194 ----- .../src/layouts/reservation/SectionHead.tsx | 52 -- .../site/src/layouts/reservation/Shell.tsx | 353 --------- .../site/src/layouts/reservation/index.ts | 10 - solution/site/src/layouts/studio/Hero.tsx | 54 -- solution/site/src/layouts/studio/Rooms.tsx | 112 --- .../site/src/layouts/studio/SectionHead.tsx | 57 -- solution/site/src/layouts/studio/Shell.tsx | 217 ------ solution/site/src/layouts/studio/index.ts | 8 - solution/site/src/lib/layout.ts | 51 +- solution/site/src/lib/ui/Section.tsx | 58 +- solution/site/src/pages/HomePage.tsx | 136 ---- solution/site/src/pages/SectionList.tsx | 94 +++ solution/site/src/pages/index.ts | 2 +- solution/site/src/sections/GallerySection.tsx | 67 +- solution/site/src/sections/HeroPension.tsx | 225 ------ solution/site/src/sections/HeroSection.tsx | 36 +- solution/site/src/sections/HeroSplit.tsx | 124 --- solution/site/src/sections/SiteHeader.tsx | 20 +- solution/site/src/sections/UnitsBands.tsx | 136 ---- solution/site/src/sections/UnitsSection.tsx | 66 +- solution/site/src/sections/UnitsTabs.tsx | 150 ---- solution/site/src/sections/blog.test.tsx | 4 +- .../src/sections/hero-catchphrase.test.tsx | 6 +- .../site/src/sections/intro-summary.test.tsx | 10 +- solution/site/src/sections/items/common.tsx | 128 +--- solution/site/src/sections/items/index.ts | 7 +- .../site/src/sections/items/reading.test.tsx | 18 +- .../site/src/sections/local-guide.test.tsx | 8 +- solution/site/src/sections/review.test.tsx | 4 +- .../site/src/sections/stay-guide.test.tsx | 10 +- solution/site/src/sections/weather.test.tsx | 4 +- solution/site/src/seo/jsonld.ts | 166 +--- 183 files changed, 1824 insertions(+), 15243 deletions(-) create mode 100644 docs/TEMPLATES.md create mode 100644 postgres-init/migrations/0023_template_ids_without_industry.sql create mode 100644 solution/backend/common/template_catalog.py delete mode 100644 solution/frontend/src/features/builder/canvas/SlateScriptRenderer.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/addable.ts delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/AddressCard.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/CtaLink.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/EmptyStateNotice.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/FeatureCard.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/HiddenFieldsNotice.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/InfoRow.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/Lightbox.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/PhotoTile.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/Pill.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/PlaceRow.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/PriceRow.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/Rail.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/SectionFrame.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/SectionHeading.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/primitives/index.ts delete mode 100644 solution/frontend/src/features/builder/canvas/registry.ts delete mode 100644 solution/frontend/src/features/builder/canvas/types.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/common.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionGallery.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionNotice.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/faq/FaqAccordion.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/faq/FaqOpenList.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/faq/FaqTwoColumn.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/faq/useFaqList.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/hero/HeroSplit.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/info/InfoCards.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/info/InfoInline.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/info/InfoTable.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryCta.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryForm.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/intro/IntroCentered.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/intro/IntroSideBySide.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/intro/IntroStory.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/items/common.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/items/items.css delete mode 100644 solution/frontend/src/features/builder/canvas/variants/itinerary/ItineraryTickets.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/WeatherPanel.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/types.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/local/walking.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/map/MapCompact.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/map/MapDetailed.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/menu/MenuGrid.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/menu/MenuList.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/menu/MenuPriceTable.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/menu/useMenuItems.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/people/PeopleFilmstrip.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/photos/PhotosCarousel.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/photos/PhotosGrid.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/photos/PhotosMasonry.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/photos/PhotosWithVideos.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/photos/VideoCard.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/postcard/PostcardStack.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/programs/ProgramsCards.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/programs/ProgramsTable.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/programs/useProgramItems.ts delete mode 100644 solution/frontend/src/features/builder/canvas/variants/quiz/QuizFlip.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/reading/ReadingRail.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/rooms/RoomsCarousel.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/rooms/RoomsGrid.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/rooms/RoomsList.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/songs/SongsTurntable.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/space/SpaceList.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/space/SpaceZones.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/video/VideoFrame.tsx delete mode 100644 solution/frontend/src/features/builder/canvas/variants/weather/WeatherSection.tsx rename solution/frontend/src/features/{dev => builder}/palette.ts (67%) create mode 100644 solution/frontend/src/features/builder/sections/addable.ts rename solution/frontend/src/features/builder/{canvas => sections}/dataSpec.ts (90%) rename solution/frontend/src/features/builder/{canvas => sections}/thumbs.tsx (94%) delete mode 100644 solution/frontend/src/features/dev/DragCarousel.tsx delete mode 100644 solution/frontend/src/features/dev/mockProps.ts delete mode 100644 solution/frontend/src/features/dev/tokenGroups.ts delete mode 100644 solution/frontend/src/features/social/SocialCanvasSection.tsx delete mode 100644 solution/frontend/src/hooks/useLocalGuide.ts delete mode 100644 solution/frontend/src/pages/DevShowcasePage.tsx create mode 100644 solution/shared/src/data/templates.json create mode 100644 solution/shared/src/lib/catalog.ts create mode 100644 solution/site/src/app.test.tsx create mode 100644 solution/site/src/layouts/basic/Frame.tsx delete mode 100644 solution/site/src/layouts/editorial/Hero.tsx delete mode 100644 solution/site/src/layouts/editorial/Rooms.tsx delete mode 100644 solution/site/src/layouts/editorial/SectionHead.tsx delete mode 100644 solution/site/src/layouts/editorial/Shell.tsx delete mode 100644 solution/site/src/layouts/editorial/index.ts delete mode 100644 solution/site/src/layouts/oasi/Hero.tsx delete mode 100644 solution/site/src/layouts/oasi/Rooms.tsx delete mode 100644 solution/site/src/layouts/oasi/SectionHead.tsx delete mode 100644 solution/site/src/layouts/oasi/Shell.tsx delete mode 100644 solution/site/src/layouts/oasi/index.ts rename solution/site/src/layouts/paper/{Shell.tsx => Frame.tsx} (99%) delete mode 100644 solution/site/src/layouts/paper/index.ts delete mode 100644 solution/site/src/layouts/pastel/Hero.tsx delete mode 100644 solution/site/src/layouts/pastel/Rooms.tsx delete mode 100644 solution/site/src/layouts/pastel/SectionHead.tsx delete mode 100644 solution/site/src/layouts/pastel/Shell.tsx delete mode 100644 solution/site/src/layouts/pastel/index.ts delete mode 100644 solution/site/src/layouts/reservation/Hero.tsx delete mode 100644 solution/site/src/layouts/reservation/Rooms.tsx delete mode 100644 solution/site/src/layouts/reservation/SectionHead.tsx delete mode 100644 solution/site/src/layouts/reservation/Shell.tsx delete mode 100644 solution/site/src/layouts/reservation/index.ts delete mode 100644 solution/site/src/layouts/studio/Hero.tsx delete mode 100644 solution/site/src/layouts/studio/Rooms.tsx delete mode 100644 solution/site/src/layouts/studio/SectionHead.tsx delete mode 100644 solution/site/src/layouts/studio/Shell.tsx delete mode 100644 solution/site/src/layouts/studio/index.ts delete mode 100644 solution/site/src/pages/HomePage.tsx create mode 100644 solution/site/src/pages/SectionList.tsx delete mode 100644 solution/site/src/sections/HeroPension.tsx delete mode 100644 solution/site/src/sections/HeroSplit.tsx delete mode 100644 solution/site/src/sections/UnitsBands.tsx delete mode 100644 solution/site/src/sections/UnitsTabs.tsx diff --git a/AGENTS.md b/AGENTS.md index 8643398..2109b7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ | 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) | | **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) | | **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) | +| **템플릿** 추가 · 렌더링 순서 · frontend/shared/site 역할 | [docs/TEMPLATES.md](docs/TEMPLATES.md) | --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 73b51e1..e7ed3e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -126,7 +126,7 @@ o2o-web4ai/ │ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드 │ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트) │ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더) -│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰) +│ └─ shared/ frontend·site·백엔드 계약 (템플릿 목록 · SitePayload · slug · 토큰) │ ├─ admin/ 우리 — 전체 사이트 운영 │ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend @@ -142,6 +142,8 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 ### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다 +세 폴더가 각각 무엇을 하는지, 템플릿이 그려지는 순서는 [TEMPLATES.md](TEMPLATES.md)에 있다. + 같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라 발행 사이트에 그대로 쓰면 크롤러가 `
` 만 읽고 떠난다. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 9b83f95..77cdb1c 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -70,7 +70,7 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 · [에디터] 템플릿 고르기 ─────────────→ sites.template_id - 색·서체·섹션 순서/on-off ──→ sites.theme (JSONB) + 색·섹션 순서/on-off ───────→ sites.theme (JSONB) 섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행) 주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m 미리보기 ──────────────────→ GET /v1/place/{id}/site/preview @@ -225,8 +225,8 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다. | 칸 | 무엇 | 왜 서버에 두나 | |---|---|---| -| `template_id` | 사장님이 고른 템플릿 키 | 서버는 **해석하지 않고 보관·반환만** 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다 | -| `theme` (JSONB) | 색·서체·**섹션 순서/on-off/배리에이션** | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 | +| `template_id` | 사장님이 고른 템플릿 id(`simple` `magazine` `retro` `paper`). NULL 이면 업종 기본 템플릿 | 템플릿 목록은 `solution/shared/src/data/templates.json` 한 파일이고, 서버도 그 파일을 읽어 업종이 못 쓰는 값은 저장·발행 때 거절한다([TEMPLATES.md](TEMPLATES.md)) | +| `theme` (JSONB) | 색·**섹션 순서/on-off** (서체·모서리 같은 모양은 템플릿이 정하므로 저장값을 쓰지 않는다) | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 | | `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 | | `current_version_id` | 지금 나가 있는 버전 | | | `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6d22c82..292fbba 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -226,7 +226,7 @@ 골라도 그 자리가 비었다. 그래서 서버가 채운다. **2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더 -(`canvas/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는 +(당시 `canvas/dataSpec.ts`, 지금은 `builder/sections/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는 그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿 설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다. → 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다 @@ -394,7 +394,7 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못 key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다. - 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다. 업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다 - (`frontend … canvas/variants/faq/useFaqList.ts` 주석). + (`frontend … canvas/variants/faq/useFaqList.ts` 주석, 이 파일은 2026-09-28 배치 고르기와 함께 지웠다). - 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다. - ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다. 그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 — diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 0aa391d..d022d6e 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,45 @@ # 개발 일지 +## 2026-09-28 — 템플릿 정의를 한 파일로 모았다 + +템플릿 정보가 빌더, 렌더러, 백엔드에 따로따로 적혀 있어서 서로 어긋나 있었다. 백엔드 기본값이 +존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서 +달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다. + +- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다. +- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용). +- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. +- 레이아웃은 `basic`과 `paper` 둘만 남겼다. 연결 안 된 레이아웃 5개, 배치 고르기, 서체 선택, 빌더 캔버스를 지웠다. +- 템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼지고, 빌더에 그 안내가 뜬다. +- 바뀐 동작: 저장된 모양(look)과 배치 선택은 무시한다. 레트로 사진은 캐러셀에서 그리드로 바뀐다. + 병원은 날씨·주변 정보가 기본으로 꺼진다. 모두 재발행할 때부터 적용된다. + +구조와 새 템플릿 추가 방법은 [TEMPLATES.md](TEMPLATES.md). + +**검증** — shared·site·frontend·admin `tsc`, site `eslint`·`vitest` 105개, frontend `vite build` 통과. +백엔드는 DB 없이 도는 테스트 41개 통과, DB가 필요한 테스트는 로컬 DB 접속 문제로 못 돌렸다. + +## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로 + +admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안 +갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트 +하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다. + +- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부 + `RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` / + `crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다 + (`UserRole` 주석 원칙을 내부 화면에도 지킨다). +- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`). + `AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다. + ★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐, + 빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다. + 실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다. +- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만. + +**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트· +스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` · +`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요. + ## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로 admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안 diff --git a/docs/SOCIAL.md b/docs/SOCIAL.md index 2057475..ce4fd61 100644 --- a/docs/SOCIAL.md +++ b/docs/SOCIAL.md @@ -27,7 +27,7 @@ DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정) - 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다. - 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다. 알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다. -- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다. +- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 레이아웃의 본문 마지막, footer 앞에 최신 3건을 굽는다. Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다. ## 연동 준비 — 한 번만 하는 일 diff --git a/docs/TEMPLATES.md b/docs/TEMPLATES.md new file mode 100644 index 0000000..2b80df1 --- /dev/null +++ b/docs/TEMPLATES.md @@ -0,0 +1,177 @@ +# 템플릿과 렌더링 + +사장님이 고르는 "템플릿"이 어디에 정의돼 있고, 화면에 어떤 순서로 그려지는지 적은 문서다. +2026-09-28에 구조를 한 번 갈아엎었고, 이 문서는 그 뒤의 모습이다. + +## 1. 세 폴더가 하는 일 + +`solution/` 밑의 세 폴더는 하는 일이 다르다. 한 줄로 말하면 이렇다. + +| 폴더 | 누가 보나 | 하는 일 | +|---|---|---| +| `shared/` | 아무도 직접 안 본다 | 나머지 둘과 백엔드가 **같이 쓰는 약속**을 둔다. 템플릿 목록, payload 모양, 슬러그 규칙 | +| `frontend/` | 사장님 | **빌더.** 템플릿·색·섹션을 고르고 내용을 고친다. 고른 값은 서버에 저장만 한다 | +| `site/` | 손님, 검색엔진, AI | **발행된 사이트를 그리는 쪽.** 서버가 만든 payload를 받아 HTML로 굽는다 | + +조금 더 풀면: + +- **shared** 는 코드라기보다 계약서다. 템플릿이 몇 개인지, 이름이 뭔지, 어느 업종이 뭘 쓸 수 + 있는지는 전부 `shared/src/data/templates.json` 한 파일에 있다. TS 쪽은 + `shared/src/lib/catalog.ts`가, 파이썬 쪽은 `backend/common/template_catalog.py`가 이 파일을 + 그대로 읽는다. 그래서 템플릿 정보가 두 군데로 갈라질 수 없다. +- **frontend** 는 사이트를 직접 그리지 않는다. 미리보기도 site가 그린 화면을 iframe으로 + 띄울 뿐이다. 빌더 안에 따로 그리는 코드를 두면 미리보기와 발행본이 조금씩 달라지는데, + 예전에 실제로 그랬다. +- **site** 는 DB도 API도 모른다. payload JSON 하나만 받으면 사이트 한 장을 그린다. 발행 때는 + 워커가 부르는 Node 스크립트로 HTML을 굽고, 미리보기 때는 브라우저에서 같은 코드로 그린다. + +## 2. 템플릿은 무엇으로 이뤄지나 + +`templates.json`에 템플릿 하나는 이렇게 생겼다. + +| 칸 | 뜻 | +|---|---| +| `name` · `tag` · `description` | 빌더에서 사장님이 보는 이름과 설명 | +| `layout` | 어떤 뼈대로 그릴지. 지금은 `basic`과 `paper` 두 개 | +| `colors` | 기본 색. 사장님이 팔레트를 고르면 그 색이 위에 덮인다 | +| `look` | 서체, 모서리, 그림자, 섹션 간격. 사장님이 못 바꾼다 | +| `addSections` | 이 템플릿을 고르면 새로 켜지는 섹션. 레트로의 일력·영상 같은 것 | + +지금 템플릿은 네 개다. + +| id | 이름 | 뼈대 | +|---|---|---| +| `simple` | 심플 | basic | +| `magazine` | 매거진 | basic | +| `retro` | 레트로 | basic | +| `paper` | 고택 | paper | + +심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택만 머리글, 첫 화면, 객실 목록, +섹션 제목 모양이 따로 있다. + +업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가 +기본이고, 병원은 심플과 매거진만 쓸 수 있다. + +### 뼈대(레이아웃)는 필요한 것만 바꾼다 + +레이아웃은 `site/src/layouts/`에 폴더 하나씩이다. 한 레이아웃이 가질 수 있는 건 셋이다. + +- `Frame`: 머리글, 본문 자리, 바닥글. 이건 꼭 있어야 한다. +- `SectionHead`: 섹션 제목 모양. 없으면 공용 제목을 쓴다. +- `sections`: 섹션별로 바꿔 그릴 컴포넌트. 여기 안 적은 섹션은 공용 컴포넌트를 그대로 쓴다. + +고택은 첫 화면·객실·제목만 바꿨고, FAQ나 오시는 길은 심플과 같은 컴포넌트를 쓴다. 새 +레이아웃을 만들 때도 전부 새로 그릴 필요 없이 다르게 보여야 하는 섹션만 만들면 된다. + +### 이상한 값이 들어오면 멈춘다 + +DB에 모르는 템플릿 id가 들어 있으면, 조용히 기본값으로 굽지 않고 멈춘다. + +- 저장할 때: 그 업종이 못 쓰는 템플릿이면 저장을 거절한다. +- 미리보기: 서버가 422를 주고, 화면에 에러가 뜬다. +- 발행: 잡이 실패로 끝난다. + +예전에는 모르는 값이 오면 기본 템플릿으로 슬쩍 구웠다. 그러면 사장님이 고른 디자인과 다른 +사이트가 나가도 아무도 모른다. + +## 3. 새 템플릿을 추가할 때 + +### 기존 뼈대를 쓰는 경우 (색·서체만 다른 템플릿) + +`templates.json` 한 파일만 고치면 된다. + +1. `templates`에 새 항목을 넣는다. id는 영어 소문자로 짓고, 업종 이름은 붙이지 않는다. +2. 쓸 수 있게 할 업종의 `industries.<업종>.templates` 목록에 그 id를 넣는다. +3. 기본 템플릿으로 삼을 거면 `defaultTemplate`도 바꾼다. + +타입(`TemplateId`)은 JSON 키에서 자동으로 뽑히므로 손댈 곳이 없다. 빌더 목록, 미리보기, 백엔드 +검증에 자동으로 들어간다. 레이아웃 이름을 틀리게 적거나 업종 목록에 없는 id를 적으면, 앱이 +뜰 때 바로 에러가 난다. + +### 새 뼈대가 필요한 경우 + +위 세 단계에 더해서 다음을 한다. + +1. `site/src/layouts/<새이름>/Frame.tsx`를 만든다. 바꿔 그릴 섹션이 있으면 같은 폴더에 둔다. +2. `site/src/layouts/index.ts`의 `LAYOUTS`에 한 줄 넣는다. +3. `shared/src/types/builder.ts`의 `LayoutId`에 이름을 넣는다. +4. `shared/src/lib/catalog.ts`의 `LAYOUT_IDS`에도 넣는다. + +2~4를 하나라도 빠뜨리면 타입체크나 앱 시작 단계에서 걸린다. + +### 새 섹션이 딸려 오는 경우 + +`addSections`에 적는 섹션은 이미 있는 섹션이어야 한다. 섹션 자체를 새로 만드는 건 템플릿과 +별개의 일이다. site의 섹션 컴포넌트, 빌더의 섹션 목록, payload 모양을 다 만져야 한다. + +### 올릴 때 + +JSON은 빌드할 때 번들과 이미지에 들어간다. 그래서 템플릿을 추가하면 backend·worker·site· +frontend를 전부 다시 빌드해야 한다. 하나만 올리면 빌더에는 보이는데 저장이 거절되는 식으로 +어긋난다. + +DB는 건드릴 필요가 없다. 이미 발행된 사이트는 사장님이 다시 발행하기 전까지 그대로다. + +## 4. 렌더링 순서 + +### 빌더에서 고칠 때 (미리보기) + +``` +사장님이 빌더에서 템플릿·색·섹션을 바꾼다 + │ frontend stores/builder.ts (템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼진다) + ▼ +서버에 저장한다 + │ 템플릿 POST /v1/place/{id}/site/template → sites.template_id + │ 색·섹션 POST /v1/place/{id}/site/theme → sites.theme + ▼ +저장이 끝나면 미리보기 iframe을 다시 연다 + │ frontend features/builder/SitePreview.tsx (features/publish/siteTheme.ts 의 저장 완료 신호를 듣는다) + │ iframe 주소 /preview?placeId=… (site가 미리 구워 둔 빈 껍데기 페이지) + ▼ +껍데기 안의 site 코드가 서버에 payload를 달라고 한다 + │ site entry-client.tsx renderPreview + │ GET /v1/place/{id}/site/preview + │ backend services/site_payload.py — 발행 때와 같은 함수로 payload를 만든다 + ▼ +템플릿 id를 확인하고 그린다 + │ 모르는 id면 여기서 에러 문구를 띄우고 멈춘다 + │ App.tsx → templates.json의 layout을 보고 LAYOUTS에서 뼈대를 고른다 + │ Frame 안에 SectionList가 섹션을 순서대로 그린다 + ▼ +다 그렸다고 빌더에 알린다 (postMessage) → 빌더가 로딩 표시를 걷는다 +``` + +### 발행할 때 + +``` +사장님이 "발행하기"를 누른다 + ▼ +jobs 표에 BUILD 잡이 들어간다 + ▼ +워커가 잡을 집는다 backend services/build_service.py run_build + │ 1. 상호명·업종이 있는지, 사실 값이 확인됐는지 본다. 아니면 발행 실패 + │ 2. 템플릿 id 확인 common/template_catalog.py — 모르면 발행 실패 + │ 3. payload JSON 만들기 services/site_payload.py + │ → site/payloads/.json 에 떨어뜨린다 + ▼ +워커가 Node 렌더러를 실행한다 site scripts/prerender.ts + │ 1. 공개하면 안 되는 값을 한 번 더 걸러 낸다 shared lib/facts.ts + │ 2. 남의 도메인 사진을 우리 서버로 내려받는다 + │ 3. React로 HTML 문자열을 만든다 site entry-server.tsx → App.tsx + │ 4. 검색용 JSON-LD, llms.txt를 같이 만든다 + │ 5. payload를 HTML 안에 심는다 (window.__SITE_PAYLOAD__) + │ → out/versions//<버전>/ 에 쓴다 + ▼ +결과를 확인하고 공개 주소를 새 버전으로 바꾼다 + │ out/s/ 링크를 새 버전 폴더로 갈아 끼운다 (PUBLISH_VERSION.md) + │ DB에 버전과 발행 기록을 남긴다 + ▼ +손님이 /s/ 에 들어온다 + │ nginx가 구워 둔 HTML을 그대로 준다. 검색엔진은 여기까지만 읽는다 + ▼ +브라우저가 JS를 받아 화면을 이어받는다 site entry-client.tsx hydrateRoot + 심어 둔 payload로 같은 화면을 다시 만들어서, 버튼·달력 같은 동작을 붙인다 +``` + +두 흐름의 차이는 하나다. 미리보기는 브라우저가 처음부터 그리고, 발행은 서버에서 미리 그려 둔 +HTML에 브라우저가 동작만 붙인다. 그리는 코드(`App.tsx`)는 같다. diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 52e94df..1188f67 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -396,7 +396,7 @@ CREATE TABLE IF NOT EXISTS public.sites ( place_id uuid NOT NULL, -- 사업장과 1:1 domain VARCHAR(255) NULL, path_prefix VARCHAR(100) NULL, - template_id VARCHAR(100) NULL, -- 사장님이 고른 템플릿 키. ★ 서버는 해석하지 않고 보관·반환만 한다 — 목록은 프론트가 소유한다 + template_id VARCHAR(100) NULL, -- 템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿 theme JSONB NULL, -- ★ 색·서체·섹션 순서/on-off/배리에이션. 내용은 site_sections 로 나갔다. templateId 는 위 컬럼이 소유한다(중복 보관 금지) status SMALLINT NOT NULL DEFAULT 1, -- SiteStatus: 1=draft 2=review 3=published 4=suspended 5=unpublished current_version_id uuid NULL, -- site_versions.site_version_id @@ -407,8 +407,8 @@ CREATE TABLE IF NOT EXISTS public.sites ( deleted BOOLEAN NOT NULL DEFAULT FALSE ); -COMMENT ON COLUMN public.sites.template_id IS '사장님이 고른 템플릿 키. NULL 이면 업종 기본 템플릿으로 굽는다.'; -COMMENT ON COLUMN public.sites.theme IS '에디터가 정한 디자인. {"colors":{...},"fontStyle":"...","sections":[{"id","name","enabled","locked","variantId"}]} — 서버는 해석하지 않고 그대로 보관·반환한다(목록은 프론트가 소유). NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다. templateId 는 sites.template_id 가 소유한다.'; +COMMENT ON COLUMN public.sites.template_id IS '템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿으로 굽는다.'; +COMMENT ON COLUMN public.sites.theme IS '색·섹션. {"colors":{...},"look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","body","data"}]}. 모양(look)은 발행 때 템플릿 정의가 정한다.'; -- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다. -- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%). diff --git a/postgres-init/migrations/0023_template_ids_without_industry.sql b/postgres-init/migrations/0023_template_ids_without_industry.sql new file mode 100644 index 0000000..80a6f0d --- /dev/null +++ b/postgres-init/migrations/0023_template_ids_without_industry.sql @@ -0,0 +1,21 @@ +-- 템플릿 id에서 업종 접두어를 뗀다(stay-retro → retro). 모르는 값과 업종 허용 목록 밖의 값은 NULL(업종 기본)로 되돌린다. +-- 허용 목록은 solution/shared/src/data/templates.json 이다. 재실행해도 결과가 같다. + +UPDATE sites +SET template_id = CASE + WHEN template_id ~ '^(stay|cafe|restaurant|clinic)-(simple|magazine|retro|paper)$' + THEN regexp_replace(template_id, '^[a-z]+-', '') + ELSE NULL +END +WHERE template_id IS NOT NULL + AND template_id NOT IN ('simple', 'magazine', 'retro', 'paper'); + +UPDATE sites AS s +SET template_id = NULL +FROM places AS p +WHERE p.place_id = s.place_id + AND p.category = 4 + AND s.template_id NOT IN ('simple', 'magazine'); + +COMMENT ON COLUMN public.sites.template_id IS '템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿으로 굽는다.'; +COMMENT ON COLUMN public.sites.theme IS '색·섹션. {"colors":{...},"look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","body","data"}]}. 모양(look)은 발행 때 템플릿 정의가 정한다.'; diff --git a/solution/backend/Dockerfile b/solution/backend/Dockerfile index 3e49c19..d4c289f 100644 --- a/solution/backend/Dockerfile +++ b/solution/backend/Dockerfile @@ -24,6 +24,7 @@ RUN playwright install --with-deps chromium COPY solution/backend ./solution/backend COPY admin/backend ./admin/backend +COPY solution/shared/src/data ./solution/shared/src/data ENV APP_ENV=local diff --git a/solution/backend/Dockerfile.worker b/solution/backend/Dockerfile.worker index 4f1ecd8..b774640 100644 --- a/solution/backend/Dockerfile.worker +++ b/solution/backend/Dockerfile.worker @@ -56,6 +56,7 @@ RUN playwright install --with-deps chromium COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node COPY solution/backend ./solution/backend +COPY solution/shared/src/data ./solution/shared/src/data # ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로 # payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야 # 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다. diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 592342b..e65846e 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -504,16 +504,9 @@ class sites(MainTableMixin, MAIN_BASE): place_id = Column(UUID(as_uuid=True), nullable=False) domain = Column(String(255), nullable=True) path_prefix = Column(String(100), nullable=True) - # 사장님이 고른 템플릿 키(프론트 배리에이션 레지스트리의 id). 서버는 해석하지 않고 보관·반환만 한다 — - # 템플릿 목록은 프론트가 소유하므로, 서버가 값을 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다. - # NULL 이면 발행 잡이 업종 기본 템플릿으로 굽는다(services/site_payload). + # 템플릿 id(solution/shared/src/data/templates.json). NULL이면 업종 기본 템플릿으로 굽는다. template_id = Column(String(100), nullable=True) - # 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션). template_id 와 같은 이유로 서버에 저장한다 — - # 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본 모양으로 굽고, 고른 디자인과 발행본이 갈린다. - # ★ 컬럼으로 펼치지 않고 jsonb 로 통째로 담는 이유: 섹션 목록·배리에이션 키·색 토큰 이름은 - # 프론트가 소유한다. 펼치면 프론트가 항목 하나 늘릴 때마다 마이그레이션이 따라와야 한다. - # ★ templateId 는 여기 넣지 않는다 — 위 template_id 컬럼이 소유한다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다. - # NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload). + # 색·섹션(순서·on/off·본문). 내용 키는 프론트가 소유하므로 jsonb로 통째로 담는다. theme = Column(JSONB, nullable=True) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value) current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id diff --git a/solution/backend/common/template_catalog.py b/solution/backend/common/template_catalog.py new file mode 100644 index 0000000..fe8c5c2 --- /dev/null +++ b/solution/backend/common/template_catalog.py @@ -0,0 +1,41 @@ +"""업종·템플릿 정의. 프론트와 같은 파일(solution/shared/src/data/templates.json)을 읽는다.""" +import json +from pathlib import Path + +from common.enums import PlaceCategory + +CATALOG_PATH = Path(__file__).resolve().parents[2] / "shared" / "src" / "data" / "templates.json" + +_CATALOG = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + +TEMPLATES: dict = _CATALOG["templates"] +INDUSTRIES: dict = _CATALOG["industries"] + +_INDUSTRY_BY_CATEGORY = { + PlaceCategory.LODGING.value: "stay", + PlaceCategory.CAFE.value: "cafe", + PlaceCategory.RESTAURANT.value: "restaurant", + PlaceCategory.CLINIC.value: "clinic", +} + + +class UnknownTemplate(ValueError): + pass + + +def industry_of(category: int) -> dict: + key = _INDUSTRY_BY_CATEGORY.get(category) + if key is None: + raise ValueError(f"업종 코드에 맞는 정의가 없다: {category}") + return INDUSTRIES[key] + + +def is_allowed(category: int, template_id: str) -> bool: + return template_id in industry_of(category)["templates"] + + +def resolve_template_id(category: int, stored: str | None) -> str: + template_id = (stored or "").strip() or industry_of(category)["defaultTemplate"] + if not is_allowed(category, template_id): + raise UnknownTemplate(f"업종 {category}에서 쓸 수 없는 템플릿: {template_id}") + return template_id diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 77f86bc..087ef04 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -22,10 +22,7 @@ class SiteProtocol(WebPacketProtocol): class Req_StartBuild(SiteProtocol): - """정적 빌드 시작. - - publish=true 면 발행 검수 게이트를 통과했을 때 바로 발행까지 한다. - ★ 게이트를 통과하지 못하면 발행되지 않는다 — 우회 옵션은 없다.""" + """정적 빌드 시작.""" publish: bool = False @@ -37,12 +34,7 @@ class Res_StartBuild(Res_WebPacketProtocol): class Req_Rollback(SiteProtocol): - """예전 버전으로 공개 주소를 되돌린다. - - ★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면 - (보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서 - 돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는 - 그대로 통과한다).""" + """재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다.""" target_version: int @@ -66,23 +58,18 @@ class SiteData(WebPacketProtocol): place_id: uuid.UUID status: SiteStatus domain: Optional[str] = None - # 사장님이 고른 템플릿. 화면이 발행 전에 "지금 어느 템플릿으로 나가는지"를 보여줄 근거다. + # 사장님이 고른 템플릿. template_id: Optional[str] = None - # 저장된 디자인(색·서체·섹션). ★ 반드시 응답으로 내려줘야 한다 — - # 에디터가 다시 열렸을 때 저장된 값을 읽을 곳이 없으면, 저장은 됐는데 화면은 기본값으로 돌아간다. - # 저장할 때와 같은 모양 그대로 돌려준다(서버가 해석하지 않으므로 변형할 이유도 없다). + # 저장된 디자인(색·서체·섹션). theme: Optional[dict[str, Any]] = None current_version_id: Optional[uuid.UUID] = None published_at: Optional[datetime] = None - # 발행 썸네일(Azure Blob 공개 URL). 대표 사진을 옮긴 것이고, 만들지 못했으면 없다. + # 발행 썸네일(Azure Blob 공개 URL). thumbnail_url: Optional[str] = None class MySiteData(WebPacketProtocol): - """내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). - - ★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유). - ★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다.""" + """내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).""" place_id: uuid.UUID name: str @@ -96,9 +83,7 @@ class MySiteData(WebPacketProtocol): domain: Optional[str] = None template_id: Optional[str] = None published_at: Optional[datetime] = None - # 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). - # Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다 - # (site_service._my_site_row) — 이때는 `?v=` 가 없다. + # 목록 카드의 그림. thumbnail_url: Optional[str] = None # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. needs_rebuild: bool = False @@ -120,52 +105,19 @@ class PublishLogData(WebPacketProtocol): class Req_SiteTemplate(SiteProtocol): - """템플릿 선택 저장. - - ★ 서버는 값을 검증하지 않는다. 템플릿 목록은 프론트(배리에이션 레지스트리)가 소유하므로 - 여기서 화이트리스트를 두면 템플릿을 하나 늘릴 때마다 백엔드를 같이 고쳐야 한다. - 잘못된 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨린다 — 화면이 깨지지 않는다.""" + """템플릿 선택 저장. 업종 허용 목록(solution/shared/src/data/templates.json)에 없는 id는 거절한다.""" template_id: str = "" class Req_SiteTheme(SiteProtocol): - """디자인(색·서체·섹션) 저장. 에디터 좌측 패널과 [디자인] 탭이 만든 결과 그대로 온다. - - ★ Req_SiteTemplate 과 같은 철학이다 — 서버는 값을 해석하지도 검증하지도 않는다. - 섹션 목록도, 배리에이션 키도, 색 토큰 이름도 프론트(배리에이션 레지스트리)가 소유한다. - 여기에 화이트리스트를 두면 프론트에 섹션이나 배리에이션이 하나 늘 때마다 백엔드를 같이 고쳐야 하고, - 그 사이 사장님이 고른 값은 조용히 버려진다. 모르는 값이 들어와도 발행 잡이 업종 기본으로 - 떨어뜨리므로 화면은 깨지지 않는다. - - ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: - {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}} - sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 - (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다. - pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — - 서버는 배달부지 심판이 아니다. - - ★ colorPaletteId 는 **에디터 복원 전용**이다. 사장님이 고른 색 프리셋 id 이고, - 발행 렌더러는 이걸 안 쓰고 해석된 colors 만 쓴다. DB 에는 저장하고 응답으로도 그대로 돌려주지만, - 발행 payload 의 theme 에는 싣지 않는다 — 발행 계약(SitePayload.SiteTheme)에 없는 필드다. - - ★ templateId 는 이 body 에 없다. sites.template_id 컬럼과 POST /template 이 계속 담당한다 — - 두 곳에 두면 어느 쪽이 진짜인지 갈린다. - - ★ 딱 하나 막는 것은 크기다. 해석하지 않는 값을 그대로 보관한다는 건 곧 무엇이든 들어올 수 있다는 - 뜻이라, 상한이 없으면 jsonb 컬럼 하나가 DB 와 스냅샷을 통째로 부풀린다. - 상한(services/site_service._THEME_MAX_BYTES)은 서비스가 직렬화 크기로 잰다 — - 필드 개수로 재면 값 하나가 긴 경우를 못 막는다. - """ + """색·섹션 저장.""" theme: dict[str, Any] = {} class RenderStatusData(WebPacketProtocol): - """정적 페이지가 실제로 구워졌는지. 프리렌더가 남긴 보고서를 그대로 옮긴다. - - ★ 발행 기록(DB)과 실제 페이지(파일)는 다른 곳에 산다. 이게 없으면 프리렌더가 깨져도 - DB 는 "발행됨"이라 말하고 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.""" + """정적 페이지가 실제로 구워졌는지.""" # PENDING(아직) · STALE(낡음) · OK · FAILED state: str = "PENDING" @@ -177,9 +129,9 @@ class RenderStatusData(WebPacketProtocol): class Res_Site(Res_WebPacketProtocol): site: Optional[SiteData] = None current_version: Optional[SiteVersionData] = None - # ★ 노출값이 바뀐 뒤 다시 빌드하지 않았으면 True — 개별 재빌드 대상이라는 표시. + # 노출값이 바뀐 뒤 다시 빌드하지 않았으면 True — 개별 재빌드 대상이라는 표시. needs_rebuild: bool = False - # ★ 빌드(DB)와 렌더(정적 파일)는 다른 단계다. 빌드가 됐다고 페이지가 있는 게 아니다. + # 빌드(DB)와 렌더(정적 파일)는 다른 단계다. render: RenderStatusData = RenderStatusData() @@ -213,10 +165,7 @@ class Res_SeoAudit(Res_WebPacketProtocol): class Req_SiteSlug(SiteProtocol): - """사이트 주소(네임스페이스) 예약. - - ★ 서버가 상호명으로 자동 확정하지 않는다 — 사장님이 고른다. - 주소는 AI 검색이 색인하는 영구 식별자라, 한 번 정해지면 되돌리는 비용이 사장님 몫이 된다.""" + """사이트 주소(네임스페이스) 예약.""" slug: str = "" @@ -240,29 +189,19 @@ class Res_SiteSlug(Res_WebPacketProtocol): class Req_SiteStatus(SiteProtocol): - """발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 — - 색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다. - - ★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도 - (`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가 - '아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15). - 무엇을 할지는 부르는 쪽이 적는다.""" + """발행 상태 전이.""" action: PublishAction class ShowcaseItem(WebPacketProtocol): - """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** - - ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. - place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 - 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.""" + """랜딩 쇼케이스 카드 한 장.""" name: str category: PlaceCategory - # "강원특별자치도 양양군" 수준. 주소를 못 읽으면 없다. + # "강원특별자치도 양양군" 수준. region: Optional[str] = None - # 발행 주소. 랜딩과 발행본이 한 오리진이라 루트 상대경로로 준다(`/s/`). + # 발행 주소. url: str # 없으면 화면이 글자 카드로 떨어진다(썸네일은 발행의 부수 효과라 실패할 수 있다). thumbnail_url: Optional[str] = None @@ -286,8 +225,7 @@ class PostData(WebPacketProtocol): sent_at: Optional[datetime] = None approved_at: Optional[datetime] = None published_at: Optional[datetime] = None - # 화면은 발행완료/발행실패만 보여준다(발행 전 상태는 안 보여준다) — 승인됐는데 - # BUILD 잡이 dead-letter 로 끝났을 때만 true(PostService._latest_build_failed). + # 화면은 발행완료/발행실패만 보여준다(발행 전 상태는 안 보여준다) — 승인됐는데 BUILD 잡이 dead-letter 로 끝났을 때만 true(PostService._latest_build_failed). build_failed: bool = False @@ -321,8 +259,6 @@ class Res_GenerationHistory(Res_WebPacketProtocol): class Res_GenerateOne(Res_WebPacketProtocol): - """개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때(2026-09-17, 사장님 - 지시: "개별적으로 새로 만들수있게 해줘"). 실패하면 post 가 없다(그 날짜가 이미 찼거나 - 소재가 바닥났다).""" + """개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때.""" post: Optional[PostData] = None diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index 76dc87c..40a9774 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse from fastapi import APIRouter, Depends, Query from common.models.gmodel import PageParams, UserInfo +from common.template_catalog import UnknownTemplate from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.site_service import SiteService from .protocol import ( @@ -23,11 +24,10 @@ from .protocol import ( Res_StartBuild, ) -# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다. +# 사이트/발행 라우터. router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}}) -# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어 -# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록). +# 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어 "내 것 전부"가 들어갈 자리가 없다. my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}}) @@ -107,10 +107,8 @@ async def set_slug( path="/template", response_model=Res_Site, summary="템플릿(디자인) 선택 저장", - description="위저드에서 고른 템플릿을 sites.template_id 에 저장한다(사이트 행이 없으면 만든다). " - "★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. 길이(100자)만 막는다. " - "★ 주소와 달리 발행 뒤에도 바꿀 수 있다: 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않는다. " - "이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).", + description="sites.template_id 에 저장한다(사이트 행이 없으면 만든다). 업종 허용 목록에 없는 id는 거절한다. " + "이미 발행된 사이트면 재빌드 표시(content_updated_at)를 찍는다.", ) async def set_template( place_id: UUID, @@ -124,20 +122,10 @@ async def set_template( @router.post( path="/theme", response_model=Res_Site, - summary="디자인(색·서체·섹션) 저장", - description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다" - "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: " - "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\"," - "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. " - "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). " - "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. " - "직렬화 크기(64KB)만 막는다. " - "★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. " - "★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. " - "★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. " - "★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). " - "이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다" - "(needs_rebuild=true).", + summary="디자인(색·섹션) 저장", + description="sites.theme 에 저장한다(사이트 행이 없으면 만든다). body: " + "{\"theme\":{\"colors\",\"look\",\"colorPaletteId\",\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"body\",\"data\"}]}}. " + "배열 순서가 곧 섹션 순서다. 크기(64KB)만 막는다. 빈 값({})이면 업종 기본으로 되돌린다.", ) async def set_theme( place_id: UUID, @@ -173,7 +161,10 @@ async def site_preview( service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken), ): - payload = await service.preview_payload(user_info, str(place_id)) + try: + payload = await service.preview_payload(user_info, str(place_id)) + except UnknownTemplate as ex: + return JSONResponse(status_code=422, content={"detail": str(ex)}) if payload is None: return JSONResponse(status_code=404, content={"detail": "사업장을 찾지 못했습니다"}) return JSONResponse(content=payload) diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index a3bdf8a..66798f2 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -1,13 +1,4 @@ -"""정적 빌드 + 발행 — BUILD 잡이 하는 일. - - 스냅샷 조립 → 빌드(HTML + JSON-LD) → 발행 검수 게이트 → site_version 기록 - -★ 정적 빌드다. DB 는 여기서만 읽고, 그 결과가 site_versions.snapshot 에 박제된다. - 방문자는 DB 와 만나지 않는다. -★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다 — - places.content_updated_at 이 바뀐 사업장만 다시 빌드하면 된다. -★ 게이트를 통과하지 못하면 버전은 FAILED 로 남고 발행되지 않는다. 사유가 publish_logs 에 남는다. -""" +"""정적 빌드 + 발행 — BUILD 잡이 하는 일.""" import os import uuid @@ -28,6 +19,7 @@ from common.enums import ( SiteStatus, ) from common.logger import LOG +from common.template_catalog import UnknownTemplate, resolve_template_id from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD @@ -70,8 +62,6 @@ async def _stamp_reviews(session, place_id, version_id): return ErrorType.SUCCESS # 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함). -# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을 -# 내려받는 발행은 몇 초가 더 걸린다(mirrorMedia, prerender.ts). RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180) @@ -97,13 +87,7 @@ async def ensure_site(place_id: str) -> "sites": async def load_channel_links(place_id: str) -> list: - """채널 링크(야놀자·네이버 플레이스·인스타…). - - 스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다 — 재빌드(run_build) - 도 롤백(rollback_service.run_rollback)도 같은 함수를 쓴다. **스냅샷에는 안 싣는다** — - 롤백이 옛 스냅샷으로 다시 구워도 링크는 항상 지금 확정된 것을 보여줘야 한다(끊긴 링크를 - 옛 버전째 되살리면 안 된다). - ★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다.""" + """채널 링크(야놀자·네이버 플레이스·인스타…).""" err, rows = await DB_SESSION_MNG.execute_lambda( place_channels.DBType(), DBWRType.DB_READ.value, @@ -133,9 +117,7 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult async def run_build(job: dict) -> dict: - """BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?} - - publish=True 면 게이트를 통과했을 때 바로 발행까지 한다.""" + """BUILD 잡 핸들러.""" payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] @@ -151,9 +133,7 @@ async def run_build(job: dict) -> dict: site = await ensure_site(place_id) - # ★ 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 - # 이때 받은 값이 실린다. 실패해도 빌드는 계속한다: 곁들이 정보가 사장님 사이트 발행을 막을 이유가 없고, - # place_contents 는 직전 값을 그대로 갖고 있다. + # 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 이때 받은 값이 실린다. try: synced = await LocalContentService().sync_place(place) if not synced.result.success: @@ -161,14 +141,7 @@ async def run_build(job: dict) -> dict: except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다 LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}") - # ★ 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다 (2026-09-11 결정). - # 순서가 뒤집히면(먼저 굽고 나중에 붙이기) 발행 직후의 사이트에는 노래가 없고 몇 분 뒤 - # 조용히 생긴다 — 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다. - # 값은 발행이 30초~3분 늦어지는 것이고(Suno 폴링 상한 5분), 그건 감수한다. - # ★ 실패해도 빌드는 계속한다. 주변 정보와 같은 규칙이다 — 곁들이 하나가 사장님 사이트 - # 발행을 막을 이유가 없다. 노래 없이 나가고, 사유는 아래 로그와 place_songs 에 남는다. - # ★ 미리보기 빌드(publish=False)에는 만들지 않는다 — 유료 호출이라 눌러 보는 것만으로 - # 비용이 나가면 안 된다. + # 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다. song_result: dict | None = None if want_publish: try: @@ -177,9 +150,7 @@ async def run_build(job: dict) -> dict: except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다 song_result = {"error": f"{type(ex).__name__}: {ex}"} LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}") - # ★ 발행 자체는 계속되므로(사이트는 노래 없이 나간다) 이건 REJECTED 도 FAILED 도 - # 아니다 — 별도 종류(partial_failure)로 알린다. 발행이 실패한 게 아니라는 걸 - # 운영자가 첫 줄만 보고 알아야 한다. + # 발행 자체는 계속되므로(사이트는 노래 없이 나간다) 이건 REJECTED 도 FAILED 도 아니다 — 별도 종류(partial_failure)로 알린다. await alert_service.send_alert( kind="partial_failure", title=f"노래 생성 실패(발행은 계속) — {place_id}", @@ -187,9 +158,7 @@ async def run_build(job: dict) -> dict: dedupe_key=f"song_failed:{place_id}", ) - # ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 — - # 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories). - # 이미 있는 기간은 부르지 않으므로 매 빌드가 유료 호출이 되지는 않는다. + # 일정(LLM)은 **여기서 직접** 부른다. try: from services.itinerary_llm_service import ensure_generated @@ -201,11 +170,7 @@ async def run_build(job: dict) -> dict: snapshot = await build_snapshot(place) - # ★ 메타 태그용 검색 키워드(SiteOntology). **스냅샷에 싣는다** — payload 는 스냅샷만 보고 만들고, - # "이 버전에 어떤 키워드가 나갔나" 가 site_versions.snapshot 에 남는다(services/seo_keywords 머리주석). - # ★ 실패해도 빌드는 계속한다. 주변 정보·노래와 같은 규칙이다 — 키워드 없이 예전 제목·메타로 나간다. - # ★ 재빌드(publish=False)에도 부른다. 로컬 임베딩이라 비용이 없고, 재빌드한 버전과 발행한 버전의 - # 제목이 갈리면 "눌러 본 것과 나간 것이 다르다" 가 된다. + # 메타 태그용 검색 키워드(SiteOntology). seo: dict | None = None try: seo = await seo_keywords.fetch(place_id, snapshot) @@ -237,7 +202,6 @@ async def run_build(job: dict) -> dict: # 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다. if song_result is not None: result["song"] = song_result - # "제목이 왜 예전 그대로인가" 도 같다 — 키워드가 실렸으면 잡 결과에 보인다(없으면 로그의 [seo] 줄). if seo is not None: result["seo"] = seo now = GTime.UTC() @@ -257,8 +221,7 @@ async def run_build(job: dict) -> dict: result["build_status"] = "FAILED" result["error"] = reason LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}") - # ★ 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고 - # 운영자를 부르면 안 된다. 여기서 알리는 건 렌더·인프라가 죽은 "업무 실패"뿐이다. + # 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고 운영자를 부르면 안 된다. if gate is None: await alert_service.send_alert( kind="build_failed", @@ -269,8 +232,6 @@ async def run_build(job: dict) -> dict: return result # ---- 1차 게이트: 렌더 없이 판정 가능한 것 ---- - # ★ 미검증 fact 는 payload 를 쓰기 전에 막는다. 렌더러에 넘긴 뒤에 막으면 검증 안 된 값이 - # 디스크에 한 번 나갔다 들어오는 셈이 된다. place_name = str((snapshot.get("place") or {}).get("name") or "").strip() if not place_name: return await _fail("상호명이 없다 — 사이트를 만들 수 없다") @@ -282,13 +243,12 @@ async def run_build(job: dict) -> dict: result["gate"] = {"passed": False, **facts_gate.as_log()} return await _fail(f"{facts_gate.reason.name}: {facts_gate.as_log()}", facts_gate) + try: + resolve_template_id(int(snapshot["place"]["category"]), site.template_id) + except UnknownTemplate as ex: + return await _fail(str(ex)) + # ---- 렌더러에 넘긴다 ---- - # ★ 여기가 "발행 기록"과 "실제 페이지"를 잇는 자리다. 렌더러(solution/site)의 유일한 입력이 - # 이 payload JSON 이고, 그게 굽는 HTML 이 방문자와 크롤러가 보는 유일한 페이지다. - # ★ payload 에 실릴 발행 상태를 미리 맞춘다. 렌더러는 이 값으로 datePublished 를 굽는데, - # 발행 뒤에 payload 를 쓰던 예전 순서에서는 그게 채워져 있었다. 순서가 바뀌었다고 - # 페이지의 발행일이 비면 AI 검색이 보는 신선도 신호가 사라진다. - # ★ 게이트에서 떨어지면 DB 에는 반영하지 않는다(아래에서 실제로 쓸 때만 저장한다). if want_publish: site.status = SiteStatus.PUBLISHED.value site.published_at = site.published_at or now @@ -302,20 +262,13 @@ async def run_build(job: dict) -> dict: slug = site_payload.publish_slug(place, site) # ---- 렌더러를 직접 돌린다 ---- - # ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해 - # 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다. - # ★ payload=False(미발행)면 렌더러는 `out/versions///` 에만 굽고 공개 - # 주소(`out/s/`)는 건드리지 않는다 — emit_payload 에 실은 publish 플래그가 정한다. try: report = await render_service.render_site(payload_path, version_no, RENDER_TIMEOUT_SEC) except render_service.RenderFailed as ex: - # ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면 - # 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다. + # 발행하지 않는다. return await _fail(str(ex)) mismatches = list(report.get("mismatches") or []) - # ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고, - # 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다. unique_count_raw = report.get("uniqueContentCount") unique_count = unique_count_raw or 0 jsonld = report.get("jsonld") or [] @@ -324,10 +277,6 @@ async def run_build(job: dict) -> dict: stamp = {"jsonld": jsonld, "unique_content_count": unique_count} # ---- 2차 게이트: 렌더 산출물 기준 ---- - # ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가 - # 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다. - # 여기서 사유를 정확히 골라야 site_publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 — - # 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다. gate = publish_gate.evaluate( PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches ) @@ -337,16 +286,13 @@ async def run_build(job: dict) -> dict: return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp) if not report.get("ok"): - # ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지 - # 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패" - # 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다. + # 렌더러가 거부한 이유에 사유 코드를 붙인다. thin = publish_gate.check_unique_content(unique_count_raw) if not thin.passed: - # 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로 - # 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다. + # 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다. result["gate"] = {"passed": False, **thin.as_log()} return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp) - # 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다. + # 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). return await _fail(str(report.get("error") or "렌더 실패"), None, stamp) # DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다. @@ -359,20 +305,16 @@ async def run_build(job: dict) -> dict: return await _fail(str(ex), None, stamp) if azure_result: result["azure"] = azure_result - # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. - # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다). + # 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no) if thumbnail_url: result["thumbnail_url"] = thumbnail_url - # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다. - # 실패해도 발행은 성공이다 — 색인 통보는 부수 효과이고, 다음 발행에서 다시 보낸다. + # 정적 파일이 올라간 **뒤에** 통보한다. indexnow_result = await indexnow.submit(slug) if indexnow_result: result["indexnow"] = indexnow_result # ---- 빌드 성공 ---- - # ★ jsonld·고유콘텐츠 수는 **렌더러가 실제로 내보낸 값**이다. 백엔드가 따로 계산하지 않는다 — - # 따로 계산하던 시절엔 게이트가 통과시킨 근거와 실제 페이지가 어긋날 수 있었다. await DB_SESSION_MNG.execute_lambda_claim( site_versions.DBType(), lambda s: _site_crud.finish_version( @@ -388,14 +330,11 @@ async def run_build(job: dict) -> dict: ) result["build_status"] = "BUILT" result["routes"] = report.get("routes") - # ★ 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면 - # 지금 풀렸다는 뜻이다(정상 발행이 재개됐다). 알림이 없었으면 resolve_alert 가 조용히 - # 아무것도 안 한다(파일 머리주석). + # 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면 지금 풀렸다는 뜻이다(정상 발행이 재개됐다). await alert_service.resolve_alert(f"build_failed:{place_id}", f"발행 재개 — {place_name or place_id}") if want_publish: - # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아 - # 지난 발행의 그림이 그대로 남는다(NULL 로 밀어 카드를 비우지 않는다). + # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아 지난 발행의 그림이 그대로 남는다(NULL 로 밀어 카드를 비우지 않는다). site_update = { "status": SiteStatus.PUBLISHED.value, "current_version_id": version.site_version_id, @@ -406,9 +345,7 @@ async def run_build(job: dict) -> dict: sites.DBType(), lambda s: _site_crud.update_site(s, site.site_id, site_update), ) - # ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라, - # 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고 - # 자기 사이트가 나갔는지 알 수 없다. 목록은 사이트 행을 읽지 않는다(N+1). + # 사업장 상태도 같이 올린다. await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index b85095a..fec8d19 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -1,19 +1,4 @@ -"""발행 payload — 정적 렌더러(solution/site)가 먹는 유일한 입력 JSON. - -★ 왜 필요한가 - 빌드 잡은 지금까지 HTML 을 굽고 그 **길이만** 재고 버렸다(발행 기록은 남는데 페이지가 없었다). - 실제로 방문자에게 보여줄 페이지는 solution/site 의 SSG 가 굽는다. 그 렌더러의 유일한 입력이 - 이 payload 이므로, 빌드 잡이 이 JSON 만 파일로 떨어뜨리면 발행이 실제 페이지로 이어진다. - -★ 스키마는 solution/shared/src/types/site-payload.ts 의 `SitePayload` 다. - 필드명이 camelCase 인 이유는 그쪽이 원본이기 때문이다 — 여기서 스네이크로 바꾸면 렌더러가 못 읽는다. - 스키마가 바뀌면 schemaVersion 을 올린다(렌더러는 모르는 버전을 조용히 반쪽 렌더하지 않고 실패한다). - -★ DB 를 여기서 다시 읽지 않는다. - 입력은 이미 박제된 스냅샷(site_versions.snapshot)과 그 빌드가 만든 행들뿐이다. - 스냅샷이 정적 빌드의 경계다 — 여기서 DB 를 한 번 더 읽으면 '스냅샷과 다른 페이지'가 나올 수 있다. - (channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.) -""" +"""발행 payload — 정적 렌더러(solution/site)가 읽는 유일한 입력 JSON.""" import json import re import os @@ -32,38 +17,27 @@ from common.enums import ( SourceType, ) from common.logger import LOG +from common.template_catalog import TEMPLATES, industry_of, resolve_template_id from services.intro_summary import summarize_intro from services.stay_guide import nol_stay_guide from services.weather_notes import weather_notes -# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다. +# 렌더러가 확인하는 스키마 버전. SCHEMA_VERSION = 1 -# 출력 디렉토리. 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 env 로 둔다. -# ★ 기본값이 `solution/site/payloads` 아래인 이유 — 렌더러(prerender.ts)가 songs·out 디렉토리를 -# **자기 파일 위치 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..). 워커가 그 -# 렌더러를 subprocess 로 직접 띄우면서(render_service.py) 세 디렉토리(payloads·songs·out)가 -# 그 렌더러가 실제로 설치된 자리(`/app/solution/site/`) 아래에 나란히 있어야 한다 — -# 어긋나면 워커는 payload 를 잘 쓰는데 렌더러는 다른 곳에서 songs 를 찾다가 못 찾는다. +# 출력 디렉토리. PAYLOAD_DIR_ENV = "SITE_PAYLOAD_DIR" DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads" -# 커스텀 도메인이 없을 때 쓰는 기본 호스트. sites.domain 이 채워지면 그 값이 이긴다. -# -# ★ env 로 뺀 이유: 이 값이 canonical·og:url·사이트맵·IndexNow 통보에 **전부** 들어간다. -# 상수로 박아 두면 스테이징에 올릴 때마다 코드를 고쳐야 하고, 안 고치면 발행은 성공하는데 -# 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다). -# 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**. +# 커스텀 도메인이 없을 때 쓰는 기본 호스트(sites.domain이 채워지면 그 값이 이긴다). SITE_HOST_ENV = "SITE_PUBLIC_HOST" -# 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로 -# canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다. DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost" def _scheme(host: str) -> str: """localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙.""" return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https" -# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다. +# 링크 제목이 비었을 때 채우는 채널 이름. _CHANNEL_TITLE = { LinkChannel.YANOLJA.value: "야놀자", LinkChannel.GOODCHOICE.value: "여기어때", @@ -74,148 +48,9 @@ _CHANNEL_TITLE = { LinkChannel.ETC.value: "기타 채널", } -# 저장된 look 이 없을 때 쓰는 기본 생김새 — 에디터의 '심플' 템플릿 -# (`solution/frontend/src/data/industryData.ts` 의 LOOK.simple)과 같은 값이다. -# ★ 왜 필요한가 (실측 2026-09-08, `/s/stay-mumum-gunsan`) -# 이 키가 없으면 `` 에 --tpl-font-heading·--tpl-radius·--tpl-texture 가 아예 -# 안 실리고, 발행본은 렌더러 CSS 의 폴백으로 떨어진다. 그 폴백의 제목 서체는 -# `--font-serif`(명조)다 — 그래서 위저드를 안 돈 사업장의 발행본만 제목이 명조로, -# 모서리는 렌더러 기본값으로 나가 에디터 미리보기(고딕)와 눈에 띄게 갈렸다. -# 색은 업종 기본이 있는데 생김새만 없어서 생긴 구멍이라, 고르지 않았을 때의 모습도 정해 둔다. -_DEFAULT_LOOK = { - "fontHeading": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", - "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", - "radius": "0.75rem", - "borderWidth": "1px", - "shadow": "0 1px 2px rgb(0 0 0 / 0.06)", - "headingTracking": "-0.02em", - "headingWeight": "700", - "sectionSpace": "4rem", -} - -# '옛 항구'(stay-retro)의 생김새. 프론트 `industryData.ts` 의 `LOOK.retro` 와 **같은 값이어야 한다.** -# -# ★ 왜 서버에도 두나 — 이 값은 프론트가 소유하지만, 백엔드는 TS 를 읽을 수 없고 -# 저장값이 없는 사업장에는 이 표가 곧 발행본이다. 값이 없으면 `_DEFAULT_LOOK`(고딕·둥근 -# 모서리)으로 떨어져 **색만 갱지고 서체는 고딕인** 페이지가 나간다 — 옛 항구가 아니게 된다. -# ★ `texture` 가 이 템플릿의 정체성이다(갱지 결). `_DEFAULT_LOOK` 에는 이 칸 자체가 없다. -_LOOK_RETRO = { - "fontHeading": "'Gugi', 'Noto Sans KR', sans-serif", - "fontBody": "'Gowun Batang', 'Noto Serif KR', serif", - "radius": "0px", - "borderWidth": "2px", - "shadow": "4px 4px 0 rgb(27 26 21 / 0.16)", - # 간판체는 자간을 벌리면 글자가 흩어지고, 굵기가 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다. - "headingTracking": "0em", - "headingWeight": "400", - "sectionSpace": "4rem", - "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)" - ), -} - - -# 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다. -# ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다: -# templateId ← sites.template_id (POST /v1/place/{id}/site/template) -# 색·서체·섹션 on/off·순서·배리에이션 ← sites.theme (POST /v1/place/{id}/site/theme) -# 저장된 값이 있으면 **그게 이긴다**. 이 표는 저장값이 없을 때만 쓰인다 — -# 고르지 않은 값을 고른 것처럼 굽지 않는다. -# ★ 이 표의 섹션 목록은 에디터의 업종별 기본 목록 -# (admin `src/data/industryData.ts` 의 `sections`)과 **id·순서·이름·잠금이 1:1로 같아야 한다.** -# 여기가 에디터보다 적으면, 사장님이 에디터에서 본 섹션이 발행본에서 통째로 사라진다 — -# 저장값이 없는 사업장은 이 표가 곧 발행본이기 때문이다(실측: 날씨·실시간 예약·대관 문의). -# 에디터에 섹션을 늘릴 때는 이 표도 같이 늘린다. 어긋나면 tests/test_site_theme.py 가 잡는다. -# ★ 이 표가 여전히 필요한 이유: 위저드를 끝까지 돌지 않은 사업장, 그리고 sections 의 -# locked 판정 근거다(아래 _theme 주석 참조). 표의 세 번째 값이 locked 다. -_DEFAULT_THEME = { - PlaceCategory.LODGING.value: { - # ★ 숙박의 기본 템플릿은 '옛 항구'(stay-retro)다 (2026-09-10, 사장님 지시). - # 예전 값 "stay-o2o-editorial" 은 **어느 목록에도 없는 id** 였다 — 프론트가 가진 - # 숙박 템플릿은 stay-simple · stay-magazine · stay-retro 셋뿐이라, 이 값이 실린 - # 발행본은 에디터로 돌아왔을 때 고른 칩이 하나도 안 맞아 늘 첫 템플릿으로 그려졌다. - "templateId": "stay-retro", - "fontStyle": "옛 간판체", - # 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2. - # 주(朱) 잉크 accent 는 레트로의 정체성이라 업종 accent 로 갈아끼우지 않는다. - "colors": {"primary": "#1b1a15", "secondary": "#4c4739", "bg": "#e4dac0", - "card": "#efe7d3", "text": "#1b1a15", "accent": "#bf2f1b"}, - "look": _LOOK_RETRO, - # ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은 - # 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다. - # ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사· - # 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection). - # ★ 가요·일력·인물·연표·읽기·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 - # (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라 - # 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다. - # ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라 - # ([+ 섹션 추가] 의 '뒤집어 보는 질문'), 켜지 않으면 지역 생성분이 어디에도 안 선다. - # 기본으로 켜지 않는 것은 의도다: 손님이 예약하러 온 화면에 퀴즈를 기본값으로 - # 세우지 않는다. 넣고 싶은 사장님이 직접 넣는다. - "sections": [ - ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False), - ("event", "소식", False), - ("info", "기본 정보", True), ("booking", "예약 안내", False), - ("video", "영상", False), - ("photos", "사진 갤러리", False), ("map", "오시는 길", True), - ("festival", "계절별 축제", False), ("local", "지역 정보", False), - ("itinerary", "추천 일정", False), ("story", "지역 이야기", False), - ("faq", "자주 묻는 질문", False), ("weather", "날씨", False), - ("social", "SNS 게시글", False), - ], - }, - PlaceCategory.CAFE.value: { - "templateId": "cafe-modern-espresso", - "fontStyle": "Sleek Roast", - "colors": {"primary": "#1c1917", "secondary": "#78716c", "bg": "#ffffff", - "card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"}, - "sections": [ - ("hero", "히어로", True), ("intro", "소개", False), ("menu", "시그니처 메뉴", False), - ("info", "기본 정보", True), ("space", "공간 · 좌석 안내", False), ("photos", "사진 갤러리", False), - ("inquiry", "대관 및 단체 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False), - ("local", "주변 나들이", False), ("faq", "자주 묻는 질문", False), - ("social", "SNS 게시글", False), - ], - }, - PlaceCategory.RESTAURANT.value: { - "templateId": "rest-neat-table", - "fontStyle": "Sophisticated Table", - "colors": {"primary": "#1c1917", "secondary": "#57534e", "bg": "#ffffff", - "card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"}, - "sections": [ - ("hero", "히어로", True), ("intro", "소개", False), ("menu", "코스 및 메뉴", False), - ("info", "기본 정보", True), ("booking", "예약 · 포장 안내", False), ("photos", "사진 갤러리", False), - ("inquiry", "단체 행사 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False), - ("local", "주변 안내", False), ("faq", "자주 묻는 질문", False), - ("social", "SNS 게시글", False), - ], - }, - PlaceCategory.CLINIC.value: { - "templateId": "clinic-visual-clinic", - "fontStyle": "Visual Journey", - "colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff", - "card": "#f8fafc", "text": "#020617", "accent": "#4A9DC4"}, - "sections": [ - ("hero", "히어로", True), ("intro", "병원 소개", False), ("programs", "시술 안내", False), - ("info", "기본 정보", True), ("exhibition", "진료 안내", False), ("photos", "사진 갤러리", False), - ("inquiry", "상담 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False), - ("local", "주변 정보", False), ("faq", "자주 묻는 질문", False), - ("social", "SNS 게시글", False), - ], - }, -} - - # ── 값 변환 헬퍼 ────────────────────────────────────────────────────────── def _first_sentence(text: str) -> str: - """첫 문장. 마침표가 없으면 통째로 돌려준다. - - ★ 왜 필요한가: 히어로 아래 한 줄과 meta description 이 같은 값을 쓴다. 둘 다 **한 문장** - 자리라, 문단이 들어가면 히어로는 세 줄로 부풀고 검색 결과에서는 뒤가 잘린다. - ★ 마침표 뒤에 공백이 없어도 자른다("…입니다.일본식" 같은 생성물이 실제로 온다). - 다만 숫자 사이의 점(1.5km)은 자르지 않는다 — 뒤가 숫자면 문장 끝이 아니다. - """ + """첫 문장.""" import re as _re match = _re.search(r"[.!?](?![0-9])", text or "") @@ -235,10 +70,7 @@ def _text(value) -> str: def _iso(value) -> str | None: - """ISO8601 문자열. ★ 화면·JSON-LD 의 dateModified 로 나가는 값이라 시간대를 명시한다. - - DB 의 timestamptz 는 여기서 naive UTC 로 올라오므로(GTime 규약) UTC 를 붙인다. - naive 로 내보내면 렌더러·검색엔진이 로컬시각으로 오해한다.""" + """ISO8601 문자열.""" if value is None: return None if isinstance(value, str): @@ -251,11 +83,7 @@ def _iso(value) -> str | None: def slugify(text: str) -> str: - """solution/shared 의 toSlug 와 같은 규칙(로마자 음차하지 않는다). - - ★ 한글을 음차하면 같은 가게가 두 주소를 갖는다. 한글은 그대로 두고 퍼센트 인코딩에 맡긴다. - NFC 로 정규화하는 이유: macOS 가 준 NFD 문자열이 그대로 디렉토리명이 되면 - 같은 이름이 서로 다른 경로로 갈린다.""" + """solution/shared 의 toSlug 와 같은 규칙(로마자 음차하지 않는다).""" normalized = unicodedata.normalize("NFC", str(text or "")).strip().lower() out = [] for ch in normalized: @@ -270,9 +98,7 @@ def slugify(text: str) -> str: return slug.strip("-") -# ── 주소 구성요소 ────────────────────────────────────────────── -# 시·도 이름 → ISO 3166-2:KR 코드. `geo.region` 메타에 쓰인다(네이버·다음이 읽는 자리). -# 약칭과 정식명을 둘 다 적는다 — 외부 장소 API 가 둘을 섞어 준다("경기" / "경기도"). +# ── 주소 구성요소 ────────────────────────────────────────────── 시·도 이름 → ISO 3166-2:KR 코드. _SIDO_ISO = { "서울": "KR-11", "서울특별시": "KR-11", "부산": "KR-26", "부산광역시": "KR-26", @@ -295,18 +121,7 @@ _SIDO_ISO = { def _parse_address_parts(*addresses: str | None) -> dict: - """주소 문자열에서 시·도 / 시·군·구 / 읍·면 을 뽑는다. - - ★ **원문에 있는 조각을 그대로만 쓴다.** "경기" 를 "경기도" 로 펴지 않는다. - addressRegion·addressLocality 는 JSON-LD 로 나가고, JSON-LD 는 화면 텍스트와 - 대조된다(solution/site/src/seo/verify.ts). 화면에는 원문 주소가 그대로 찍히므로 - 정식명으로 펴는 순간 '화면에 없는 값'이 되어 발행 게이트가 막는다. - 정규화가 필요한 곳은 ISO 코드 하나뿐이고, 그건 화면 대조 대상이 아니다. - - ★ 첫 토큰이 아는 시·도가 아니면 **아무것도 돌려주지 않는다** — 추측해서 채우지 않는다. - - 도로명 주소를 먼저 보고, 못 읽으면 지번 주소로 넘어간다. - """ + """주소 문자열에서 시·도 / 시·군·구 / 읍·면 을 뽑는다.""" for raw in addresses: tokens = str(raw or "").split() if len(tokens) < 2 or tokens[0] not in _SIDO_ISO: @@ -315,7 +130,7 @@ def _parse_address_parts(*addresses: str | None) -> dict: region = tokens[0] rest = tokens[1:] - # 시·군 → (있으면) 그 아래 구. 광역시·특별시는 구가 바로 온다. + # 시·군 → (있으면) 그 아래 구. locality: list[str] = [] i = 0 while i < len(rest): @@ -342,10 +157,7 @@ def _parse_address_parts(*addresses: str | None) -> dict: def _fact_entry(spec, row: dict) -> dict: - """스냅샷 fact 1건 → FactEntry. - - label/type/unit/critical/required 는 업종 스키마가 유일한 소스다 — - 스냅샷에 라벨이 박제돼 있어도 스키마가 있으면 스키마를 따른다(라벨 오탈자 수정이 재빌드로 반영된다).""" + """스냅샷 fact 1건 → FactEntry.""" key = row.get("key") return { "key": key, @@ -354,7 +166,7 @@ def _fact_entry(spec, row: dict) -> dict: "unit": row.get("unit") or (spec.unit if spec else None), "type": (spec.type if spec else "text"), "scope": row.get("scope") or (spec.scope if spec else "place"), - # ★ status 를 그대로 싣는다. 렌더러가 selectPublishable() 로 한 번 더 거른다(2중 방어). + # status 를 그대로 싣는다. "status": row.get("status") or FactStatus.VERIFIED.value, # facts.source_type 은 NOT NULL 이라 여기 기본값은 옛 스냅샷용 안전망이다. "sourceType": row.get("source_type") or SourceType.OWNER.value, @@ -367,89 +179,33 @@ def _fact_entry(spec, row: dict) -> dict: } -def _theme(site, theme_spec: dict) -> dict: - """SiteTheme — 저장된 디자인이 이기고, 없는 것만 업종 기본으로 떨어진다. - - ★ 저장값 우선이 이 함수의 존재 이유다. 예전에는 이 자리가 업종 기본 표를 그대로 굽고 - enabled 를 True 로 박아 넣었다 — 사장님이 섹션을 끄고 순서를 바꿔도 발행본은 언제나 - 업종 기본 모양이었다. 저장할 자리(sites.theme)가 생겼으니 여기서 읽는다. - - ★ 서버는 값을 해석하지 않는다. 섹션 id 도 배리에이션 키도 색 토큰도 프론트가 소유하므로 - 모르는 값이 와도 그대로 싣는다 — 렌더러가 모르는 키를 만나면 자기 기본으로 떨어진다. - - ★ 딱 하나 서버가 우기는 것이 locked 다. 아래 _sections 주석 참조.""" +def _theme(site, category: int) -> dict: + """SiteTheme. 모양(look)은 템플릿 정의가, 색·섹션은 저장된 theme이 정한다. 잘못된 templateId면 UnknownTemplate.""" + template_id = resolve_template_id(category, _text(_get(site, "template_id"))) + template = TEMPLATES[template_id] saved = _get(site, "theme") if not isinstance(saved, dict): saved = {} - # 색: 저장값이 이기되 **업종 기본 위에 덮는다**. - # ★ 렌더러 타입(SiteTheme.colors)은 6개 키를 모두 요구한다. 저장값이 일부만 담고 있을 때 - # 그것만 실으면 나머지 색이 undefined 로 나가 화면이 깨진다 — 빠진 자리는 업종 기본이 메운다. - colors = dict(theme_spec["colors"]) + # 렌더러는 색 6개를 모두 요구한다. + colors = dict(template["colors"]) for key, value in (saved.get("colors") or {}).items(): if isinstance(value, str) and value.strip(): colors[key] = value.strip() - font_style = _text(saved.get("fontStyle")) or theme_spec["fontStyle"] - - # ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 — - # 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다. - out = { - # 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다. - # 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다). - "templateId": _text(_get(site, "template_id")) or theme_spec["templateId"], + return { + "templateId": template_id, "colors": colors, - "fontStyle": font_style, - "sections": _sections(saved.get("sections"), theme_spec["sections"]), + "look": dict(template["look"]), + "sections": _sections(saved.get("sections"), industry_of(category)["sections"]), } - # ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 — - # 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다. - # 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다. - # 저장된 look 이 있으면 그게 이긴다. 다만 **덮어쓰기가 아니라 덧칠이다** — 프론트가 - # 일부 키만 보낸 옛 저장값에 빈칸이 생기면 그 칸만 명조·렌더러 기본값으로 떨어진다. - look = saved.get("look") - cleaned = ( - {k: v for k, v in look.items() if isinstance(v, str) and v.strip()} - if isinstance(look, dict) - else {} - ) - # 업종 기본 look 이 있으면 그것을 바닥에 깐다(숙박 = 옛 항구). 없으면 공통 폴백이다. - out["look"] = {**theme_spec.get("look", _DEFAULT_LOOK), **cleaned} - return out -def _sections(saved_sections, default_spec) -> list: - """섹션 목록 — 저장된 **배열 순서**가 곧 발행본의 섹션 순서다. - - ★ locked 는 서버가 우긴다. 잠긴 섹션(히어로·기본 정보·오시는 길)은 SEO·필수 마크업 때문에 - 잠긴 것이라, 저장값이 껐다고 해도 켜서 내보낸다. 그리고 잠금 판정은 **업종 기본 표**가 하고 - 저장값의 locked 는 잠그는 방향으로만 더한다 — 저장값의 locked:false 를 그대로 믿으면 - 클라이언트가 locked 를 내려 보내는 것만으로 필수 섹션을 끌 수 있어 잠금 자체가 무의미해진다. - - ★ 저장값에 없는 기본 섹션은 **켜서** 목록 끝에 덧붙인다. - - 한때 잠기지 않은 섹션은 꺼서 붙였다 — "사장님이 목록에서 뺐다 = 안 쓰겠다는 뜻"이라고 봤다. - 그 전제가 틀렸다. 에디터에는 섹션을 **빼는 기능이 없다**(toggleSection·reorderSection 뿐, - admin/src/stores/builder.ts). 그러니 저장값에 없다는 건 "뺐다"가 아니라 - **저장할 당시 그 섹션이 아직 없었다**는 뜻이다 — 우리가 나중에 추가한 섹션이다. - - 꺼서 붙이면 새 섹션은 기존 사업장에 영원히 나오지 않는다. 에디터에는 보이는데 - 발행본에는 없는 상태가 되고(실측: 날씨 섹션), 사장님은 켠 적도 끈 적도 없는 것이 - 안 나온다고 본다. 저장값이 아예 없을 때 전부 켜서 내보내는 것과 같은 규칙으로 맞춘다. - - ★ 통째로 버리지는 않는다 — 렌더러가 섹션 이름을 알아야 에디터에서 껐을 때 같은 이름으로 - 붙고, 무엇이 꺼져 있는지도 payload 만 보고 알 수 있다. - - ★ 저장값에만 있고 업종 기본에 없는 섹션(프론트가 새로 추가한 것)은 그대로 싣는다. - 섹션 목록은 프론트가 소유한다 — 서버가 모른다고 버리면 새 섹션이 발행되지 않는다.""" - defaults = {sid: (label, locked) for sid, label, locked in default_spec} - - # 저장값이 없으면(아직 아무것도 고르지 않았다) 업종 기본을 전부 켜서 내보낸다. +def _sections(saved_sections, defaults: list[dict]) -> list: + """섹션 목록.""" + by_id = {d["id"]: d for d in defaults} if not isinstance(saved_sections, list) or not saved_sections: - return [ - {"id": sid, "name": label, "enabled": sid != "social", "locked": locked} - for sid, label, locked in default_spec - ] + return [{"id": d["id"], "name": d["name"], "enabled": d["enabled"], "locked": d["locked"]} for d in defaults] out = [] used = set() @@ -460,51 +216,29 @@ def _sections(saved_sections, default_spec) -> list: if not sid or sid in used: continue used.add(sid) - default_label, default_locked = defaults.get(sid, ("", False)) - # 서버가 아는 잠금(업종 기본)이 항상 이긴다. 저장값은 잠그는 방향으로만 보탠다. - locked = bool(default_locked) or bool(item.get("locked")) + default = by_id.get(sid, {}) + locked = bool(default.get("locked")) or bool(item.get("locked")) entry = { "id": sid, - # 사장님이 붙인 제목이 있으면 그게 발행본의 소제목이다. 없으면 업종 기본 이름, 그것도 없으면 id. - "name": _text(item.get("name")) or default_label or sid, - # ★ 잠긴 섹션은 꺼진 채로 나갈 수 없다. + "name": _text(item.get("name")) or default.get("name") or sid, "enabled": bool(item.get("enabled", True)) or locked, "locked": locked, } - # ★ 고른 배리에이션이 있을 때만 키를 붙인다. 서버는 이 값을 해석하지 않는다 — - # 비어 있으면 렌더러가 그 섹션의 기본 레이아웃으로 떨어진다(null 을 실으면 타입이 안 맞는다). - variant_id = _text(item.get("variantId")) - if variant_id: - entry["variantId"] = variant_id - # ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 — - # 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다. - # 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아 - # "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다. - # ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고, - # 섹션 제목(name)이 이미 같은 경로로 나간다. body = _text(item.get("body")) if body: entry["body"] = body - # ★ 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON. body 와 같은 이유로 그대로 싣는다. - # 서버는 파싱하지 않는다 — 모양을 검사하면 프론트가 필드를 하나 늘린 날 조용히 떨어뜨린다. - # 깨진 JSON 은 렌더러가 그 섹션만 비우고 넘어간다(shared/lib/section-data.ts). data = _text(item.get("data")) if data: entry["data"] = data out.append(entry) - for sid, label, locked in default_spec: - if sid in used: - continue - # ★ 켜서 붙인다. 저장값에 없는 건 사장님이 뺀 게 아니라 저장 당시 없던 섹션이다(위 주석). - out.append({"id": sid, "name": label, "enabled": sid != "social", "locked": bool(locked)}) + for d in defaults: + if d["id"] not in used: + out.append({"id": d["id"], "name": d["name"], "enabled": d["enabled"], "locked": d["locked"]}) return out -# WMO weather code → 한 줄 날씨. 코드 하나마다 고유 문구다(뭉치지 않는다) — ★ solution/site/src/lib/ -# use-live-weather.ts 의 WEATHER_CONDITION_BY_CODE 와 **같은 표**여야 한다. 렌더러는 프리렌더된 -# 이 값으로 그리다가 하이드레이션 뒤 최신 캐시로 덮어쓰는데, 두 곳이 다른 표를 쓰면 같은 날씨인데 -# 화면 문구가 바뀐다(사장님 눈에는 버그로 보인다). +# WMO weather code → 한 줄 날씨. _WEATHER_CONDITION_BY_CODE = { 0: "맑음", 1: "대체로 맑음", @@ -565,7 +299,7 @@ def _yyyymmdd(value) -> str: return digits if len(digits) == 8 else "" -# 시작 월 → 계절. 경계는 기상학 기준(3·6·9·12월 시작)이다 — 축제는 "몇 월에 가나"로 찾는다. +# 시작 월 → 계절. _SEASON_BY_MONTH = { 3: "봄", 4: "봄", 5: "봄", 6: "여름", 7: "여름", 8: "여름", @@ -582,7 +316,7 @@ def _festival(row: dict): return None start, end = _yyyymmdd(body.get("eventstartdate")), _yyyymmdd(body.get("eventenddate")) - # month 는 화면의 배지다(예: "10월"). 시작일이 없으면 만들지 않는다. + # month 는 화면의 배지다(예: "10월"). month = f"{int(start[4:6])}월" if start else "" if start and end and start != end: period = f"{start[:4]}.{start[4:6]}.{start[6:]} ~ {end[:4]}.{end[4:6]}.{end[6:]}" @@ -595,18 +329,10 @@ def _festival(row: dict): entry = { "name": name, "month": month, - # ★ searchQuery 만 있고 우리가 URL 을 지어내지 않는다 — 틀린 링크는 방문자를 엉뚱한 데로 보내고 - # 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙). + # searchQuery 만 있고 우리가 URL 을 지어내지 않는다 — 틀린 링크는 방문자를 엉뚱한 데로 보내고 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙). "searchQuery": name, } if start: - # ★ 계절은 **여기서 한 번만** 정한다 (실측 2026-09-10) - # `FestivalEntry.season` 계약이 "시작일에서 한 번만 정해 payload 에 싣는다" 인데 - # 아무도 안 실었다 — 그 결과 발행된 모든 사이트에서 계절 탭이 0개였다(군산·성남 모두 - # 20건 전부 빈 값). 화면은 계절이 있는 것만 탭으로 세우므로, 축제가 스무 건 있어도 - # "계절 없이 열리는 행사" 한 덩이로 쏟아졌다. `/s/stay` 시안에 4탭이 서 있는 건 - # 그 payload 의 계절을 손으로 넣었기 때문이다. - # 렌더러에서 월을 계절로 되돌리지 않는다 — 계약 주석이 금지한 자리다(수집과 갈라진다). entry["season"] = _SEASON_BY_MONTH[int(start[4:6])] # 정렬·계절 산출의 근거를 기계가 읽는 형식으로도 남긴다(`period` 는 사람이 읽는 문구다). entry["startDate"] = f"{start[:4]}-{start[4:6]}-{start[6:]}" @@ -618,11 +344,10 @@ def _festival(row: dict): description = _text(body.get("overview")) if description: entry["description"] = description - # 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다. + # 공식 홈페이지는 출처가 준 값일 때만 싣는다. if homepage.startswith("http://") or homepage.startswith("https://"): entry["officialUrl"] = homepage - # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 - # 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정). + # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 같은 모양으로 그리려면 필요하다. _put_distance(entry, body) image = _text(body.get("imageUrl")) if image: @@ -631,14 +356,7 @@ def _festival(row: dict): def _local_place(row: dict, category: str): - """LocalPlace. - - ★ 2026-09-09 부터 `body` 가 **이미 렌더러 모양**이다(`name`·`location`·`imageUrl`) — - 수집 시점에 바꿔 넣는다(`external/tour_api._normalize`). 예전에는 TourAPI 원문 이름을 - 저장하고 빌드마다 여기서 바꿔 실었다. 같은 변환을 발행할 때마다 다시 하는 셈이었고, - 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다. - 그래서 여기가 하는 일은 둘뿐이다 — 업종 라벨을 붙이고, 사이트별 거리를 표기로 바꾼다. - """ + """LocalPlace.""" body = row.get("body") or {} name = _text(body.get("name")) or _text(row.get("title")) if not name: @@ -659,8 +377,7 @@ def _local_place(row: dict, category: str): def _put_distance(entry: dict, body: dict) -> None: """distanceMeters → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다.""" - # ★ 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 — - # 공용 실체에는 거리가 없다(업장마다 다르다). 스냅샷이 그 값을 body 에 얹어 준다. + # 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 — 공용 실체에는 거리가 없다(업장마다 다르다). meters = body.get("distanceMeters") distance = _distance_text(meters) if not distance: @@ -684,21 +401,9 @@ def _distance_text(meters) -> str: def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) -> tuple[dict, str | None]: - """스냅샷의 지역 정보 → LocalContents. - - ★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 — - fact·사진과 같은 분업이다. - ★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도 - payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다. - ★ itineraries(1박2일·2박3일 각 5개)는 **스냅샷에서 읽는다.** 예전에는 이 자리에서 - 거리 기반으로 즉석 계산했다(services/itinerary.py) — 거리 계산은 공짜라 그게 맞았다. - LLM 생성으로 바뀌면서 건당 20~50초·유료가 되어 표에 저장하고 그걸 읽는다 - (services/itinerary_llm_service · tmp/superpowers/specs/2026-09-11-llm-itinerary-design.md). - ★ 여기서 DB 를 읽지 않는다. 읽는 곳은 services/snapshot._local_contents 하나다 — - 이 함수가 순수해야 "스냅샷과 다른 페이지"가 생기지 않는다(파일 상단 원칙). - ★ services/itinerary.py 는 지우지 않았다. 고도화해서 되살릴 때 이 블록을 되돌린다.""" + """스냅샷의 지역 정보 → LocalContents.""" contents = (snapshot_local or {}).get("contents") or [] - # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07). + # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다. local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []} synced_at = None @@ -707,7 +412,6 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) continue collected_at = _text(row.get("collected_at")) # syncedAt 은 화면에 "○○ 갱신"으로 그대로 노출된다 — 가장 최근 수집 시각을 쓴다. - # ★ 오래된 정보를 숨기지 않는다(렌더러 타입 주석). 그래서 최신값이 아니라 '실제 최신 수집 시각'이다. if collected_at and (synced_at is None or collected_at > synced_at): synced_at = collected_at @@ -734,10 +438,7 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) if entry: local["courses"].append(entry) elif content_type == LocalContentType.STORY.value: - # ★ 지역 이야기는 **모양을 바꾸지 않는다.** body 가 이미 렌더러 계약 - # (`shared/lib/section-data.ts` 의 SongItem·PeopleItem…) 그대로다. - # 여기서 키를 손대면 사장님이 손으로 붙여넣은 같은 종류의 JSON 과 모양이 갈린다 — - # 화면은 둘을 한 배열로 이어 그린다. + # 지역 이야기는 **모양을 바꾸지 않는다. kind = _text(row.get("kind")) items = (row.get("body") or {}).get("items") if kind and isinstance(items, list) and items: @@ -755,22 +456,11 @@ def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) def _publish_target(site, place_id: str, name: str) -> dict: - """발행 주소(origin/basePath/slug). - - ★ sites.domain 이 있으면 그게 사장님이 고른 주소다. 없으면 **임시값**이다 — - 상호명은 유일하지 않으므로 place_id 앞자리를 붙여 사이트끼리 겹치지 않게 한다. - 이 임시값이 SNS 같은 외부로 새면 되돌릴 수 없다 — SNS는 domain 확정을 요구한다. - - ★ **언제나 경로형**(`https:///s/`)이다. 서브도메인을 쓰지 않는 이유는 - 사이트가 하나 늘 때마다 DNS 레코드와 TLS 인증서를 새로 만들어야 해서다 — - 발행 시점에 그걸 대신 만들어 줄 방법이 없으니 서브도메인 주소는 화면에만 있고 - 실제로는 열리지 않는다. shared/lib/slug.ts 의 publishUrl 과 **같은 규칙**이어야 - 화면이 보여준 주소와 발행본의 주소가 갈리지 않는다.""" + """발행 주소(origin/basePath/slug).""" domain = _text(_get(site, "domain")) if domain: - # domain 컬럼에는 slug 만 들어온다(site_slug 가 검증한 값). 옛 데이터가 호스트 형태로 - # 남아 있을 수 있어 첫 라벨만 취한다. + # domain 컬럼에는 slug 만 들어온다(site_slug 가 검증한 값). slug = slugify(domain.split(".")[0]) or slugify(name) or place_id else: name_slug = slugify(name) @@ -780,31 +470,22 @@ def _publish_target(site, place_id: str, name: str) -> dict: def publish_slug(place, site) -> str: - """이 사업장 사이트의 발행 슬러그. - - ★ 렌더 보고서(payloads/.status/.json)를 찾으려면 payload 를 만들 때와 **같은 규칙**으로 - 슬러그를 구해야 한다. 그래서 여기 한 곳에서만 계산하고 밖에서는 이 함수를 부른다 — - 규칙을 두 군데 두면 보고서를 못 찾아 "아직 안 구워졌다"고 잘못 답하게 된다.""" + """이 사업장 사이트의 발행 슬러그.""" return _publish_target(site, str(_get(place, "place_id") or ""), _text(_get(place, "name")))["slug"] def publish_origin() -> str: - """발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 — - 호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳').""" + """발행본이 사는 오리진.""" return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}" def publish_url(place, site) -> str: - """이 사업장 사이트의 전체 발행 주소. 미니 블로그·SNS 초안이 문구 끝에 붙이는 링크가 - 이 값과 갈리면 안 되므로 origin·slug 조합을 여기 한 곳에서만 한다.""" + """이 사업장 사이트의 전체 발행 주소.""" return f"{publish_origin()}/s/{publish_slug(place, site)}" def primary_media(snapshot: dict) -> dict | None: - """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None. - - ★ 썸네일도 이 함수를 쓴다. 규칙을 복제하면 검색 결과에 뜨는 그림과 - 쇼케이스 카드가 다른 사진이 되고, 그건 아무도 눈치채지 못한다.""" + """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장.""" for row in (snapshot or {}).get("media") or []: if not row.get("unit_id"): return row @@ -812,10 +493,7 @@ def primary_media(snapshot: dict) -> dict | None: def region_label(*addresses: str | None) -> str | None: - """"강원특별자치도 양양군" — 시·도 + 시·군·구까지만. - - ★ 상세 주소는 붙이지 않는다. 로그인 없이 읽히는 목록(쇼케이스)에 쓰이므로 - '어느 동네인지' 를 넘어서면 안 된다.""" + """"강원특별자치도 양양군" — 시·도 + 시·군·구까지만.""" parts = _parse_address_parts(*addresses) label = " ".join(p for p in (parts.get("addressRegion"), parts.get("addressLocality")) if p) return label or None @@ -823,14 +501,7 @@ def region_label(*addresses: str | None) -> str | None: # ── payload 조립 ────────────────────────────────────────────────────────── def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> dict: - """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict). - - 순수 변환 함수다. DB 도 파일도 건드리지 않는다 — 그래야 테스트가 쉽고, - 같은 입력이면 언제나 같은 payload 가 나온다. - - ★ `publish` 는 렌더러(prerender.ts publishVersion)에게 "이 버전으로 공개 주소를 - 넘겨도 되는가"를 알리는 신호다. False(미리보기·게이트 통과 전 재빌드)면 렌더러가 - `out/versions///` 에만 굽고 `out/s/` 심볼릭 링크는 그대로 둔다.""" + """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict).""" snapshot = snapshot or {} snap_place = snapshot.get("place") or {} place_id = str(_get(place, "place_id")) @@ -849,9 +520,6 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = unit_facts.setdefault(str(entry["unitId"]), []).append(entry) # ── 사진 ───────────────────────────────────────────── - # ★ 스냅샷이 이미 걸렀다(APPROVED + alt 있음). 여기서 더 거르지 않고 모양만 바꾼다. - # ★ sourceType/originUrl 을 반드시 싣는다 — 크롤링 이미지 재게시 권리가 미결이라(DECISIONS 1-2) - # 결론이 나면 출처로 걸러낼 수 있어야 한다. 출처를 버리면 그때 다시 수집해야 한다. media = [] primary_row = primary_media(snapshot) for index, row in enumerate(snapshot.get("media") or []): @@ -865,8 +533,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = "width": row.get("width"), "height": row.get("height"), "isPrimary": is_primary, - # 출처가 없는 건 옛 스냅샷뿐이다(media.source_type 은 NOT NULL). 그때는 CRAWL 로 본다 — - # 재게시 권리가 결론 나면 걸러져야 할 쪽으로 기울이는 게 안전하다. + # 출처가 없는 건 옛 스냅샷뿐이다(media.source_type 은 NOT NULL). "sourceType": row.get("source_type") or SourceType.CRAWL.value, "originUrl": row.get("origin_url"), "unitId": unit_id, @@ -884,7 +551,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = units.append({ "unitId": unit_id, "name": unit_name, - # slug 가 URL 이 된다. 이름이 비거나 기호뿐이면 순번으로 떨어뜨린다(빈 경로를 만들지 않는다). + # slug 가 URL 이 된다. "slug": slugify(unit_name) or f"unit-{index + 1}", "sortOrder": int(row.get("sort_order") or index), "facts": unit_facts.get(unit_id, []), @@ -903,9 +570,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = "sortOrder": int(row.get("sort_order") or index), }) - # ── 채널 링크 ──────────────────────────────────────── - # 확정(confirmed_at)되지 않은 링크도 실어 보낸다 — 렌더러가 confirmed 로 한 번 더 거른다. - # 여기서 미리 빼면 "왜 안 나오는지"가 payload 만 봐서는 안 보인다. + # ── 채널 링크 ──────────────────────────────────────── 확정(confirmed_at)되지 않은 링크도 실어 보낸다 — 렌더러가 confirmed 로 한 번 더 거른다. channel_links = [] for row in links or []: channel = _get(row, "channel") @@ -924,27 +589,17 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = }) # ── 소개문 ─────────────────────────────────────────── - # ★ 여기서 문장을 지어내지 않는다. intro fact(allow_llm=true 필드)에 있는 것만 옮긴다. - # heroHeadline·tagline 은 저장되는 자리가 없어서 비운다 — 비면 렌더러가 상호명으로 대체한다. intro = _text(next((f["value"] for f in place_facts if f["key"] == "intro" and f["value"]), "")) paragraphs = [p.strip() for p in intro.split("\n") if p.strip()] if intro else [] narrative = { "about": paragraphs, - # ★ 요약은 **첫 문장**이다. 문단이 아니다. - # 계약이 "요약 한 문장"이라 적어 뒀는데(shared/site-payload.ts) 첫 문단을 통째로 - # 넣고 있었다. 그 값은 두 곳으로 나간다 — 히어로 아래 한 줄과 meta description. - # 문단이 들어가면 히어로가 세 문장을 이고 서고(실측 2026-09-10), meta description 은 - # 검색 결과에서 잘린다. 문장을 새로 생성하지는 않는다 — 있는 글의 첫 문장을 뗄 뿐이다. + # 요약은 **첫 문장**이다. "summary": _first_sentence(paragraphs[0]) if paragraphs else None, } - theme_spec = _DEFAULT_THEME.get(category) or _DEFAULT_THEME[PlaceCategory.LODGING.value] - theme = _theme(site, theme_spec) + theme = _theme(site, category) # ── 지역 정보 ──────────────────────────────────────── - # ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙). - # 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다. - # 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다. local, local_synced_at = _local( snapshot.get("local") or {}, _as_float(snap_place.get("latitude")), _as_float(snap_place.get("longitude")), @@ -982,17 +637,13 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = "category": category, "roadAddress": snap_place.get("road_address") or None, "address": snap_place.get("address") or None, - # 시·도 / 시·군·구 / 읍·면. PostalAddress 의 지역 필드와 title·geo 메타가 이걸 쓴다 — - # 없으면 지역 질의("성남 소금빵")에 걸릴 자리를 통째로 버리게 된다. + # 시·도 / 시·군·구 / 읍·면. **_parse_address_parts(snap_place.get("road_address"), snap_place.get("address")), "phone": snap_place.get("phone") or None, - # 스냅샷은 좌표를 문자열로 박제한다(Numeric 직렬화). 렌더러 타입은 number 라 여기서 되돌린다. + # 스냅샷은 좌표를 문자열로 박제한다(Numeric 직렬화). "latitude": _as_float(snap_place.get("latitude")), "longitude": _as_float(snap_place.get("longitude")), - # ★ 스냅샷이 실제로 쓴 지역 키가 이긴다. places.region_code 가 비어 있어도 - # 스냅샷이 주소에서 유도했으면(services/snapshot._local_contents) 그 값이 여기 실려야 - # 렌더러의 실시간 날씨 조회가 산다 — use-live-weather 는 regionCode 없이는 fetch 하지 않고, - # 그러면 날씨 섹션이 통째로 사라진다(에디터에는 보이는데 사이트에는 없는 그 자리). + # 스냅샷이 실제로 쓴 지역 키가 이긴다. "regionCode": ( _text((snapshot.get("local") or {}).get("region_code")) or _text(_get(place, "region_code")) @@ -1006,15 +657,9 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = "faqs": faqs, "links": channel_links, "local": local, - # ★ 가는 길(routes)은 비어 있다. local.routes 테이블에 행이 0이고, 그 테이블에 쓰는 코드 경로가 - # 아직 어디에도 없다(모델과 DDL 만 있고 수집기·입력 API 가 없다). 여기서 조회 배선을 만들어 봐야 - # 영원히 빈 결과를 도는 코드가 되고, 실제 수집기가 붙는 날 그 모양에 맞을지도 알 수 없다. - # ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석). - # 렌더러는 비면 해당 섹션을 그리지 않는다. + # 가는 길(routes)은 비어 있다. "routes": [], - # ★ 이 숙소의 노래. `audioUrl` 은 **우리 쪽 경로**다 — Suno 가 준 주소는 만료되므로 - # 파일을 받아 두고(song_service) 프리렌더가 사이트 디렉토리로 복사한 것을 가리킨다. - # 경로를 여기서 만드는 이유: 발행본의 주소 규칙(basePath + /s/)을 아는 곳이 여기다. + # 이 숙소의 노래. "songs": [ { "songId": row["song_id"], @@ -1022,8 +667,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = "lyrics": row.get("lyrics") or None, "style": row.get("style") or None, "durationSec": row.get("duration_sec"), - # 프리렌더가 복사해 놓을 자리. 파일명은 그대로 쓴다. - # basePath 자체가 이미 `/s/`(또는 서브패스 마운트라면 그 앞에 접두어)다. + # 프리렌더가 복사해 놓을 자리. "audioUrl": f"{target['basePath']}/{row['file_name']}", # 프리렌더가 원본을 찾을 때 쓰는 이름(솔루션 밖으로는 안 나간다). "fileName": row["file_name"], @@ -1031,8 +675,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = for row in (snapshot.get("songs") or []) if (row.get("file_name") or "").strip() ], - # 미니 블로그 — 숙소 소개 아래 게시판(docs/MINI_BLOG.md). 글 전부가 HTML 에 들어가고 - # 화면은 JS 로 나눠 보여준다. 페이지를 눌러 더 불러오면 크롤러가 2페이지를 못 본다. + # 미니 블로그 — 숙소 소개 아래 게시판(docs/MINI_BLOG.md). "posts": [ { "postId": row["post_id"], @@ -1042,7 +685,7 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = for row in (snapshot.get("posts") or []) if (row.get("body") or "").strip() ], - # 이용 후기 — 별점 없이 글만(2026-09-16 회의). JSON-LD 로는 내보내지 않는다. + # 이용 후기 — 별점 없이 글만. "reviews": [ { "reviewId": row["review_id"], @@ -1058,17 +701,13 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool = for r in snapshot.get("social_posts", [])], "narrative": narrative, "theme": theme, - # ★ 검색 키워드(SiteOntology). 스냅샷에 있을 때만 싣는다 — 옛 스냅샷·SiteOntology 가 꺼진 빌드에는 없고, - # 그때 렌더러는 제목·메타를 예전 그대로 굽는다(solution/site/src/seo/meta.ts). + # 검색 키워드(SiteOntology). **_seo_entry(snapshot.get("seo")), } def _seo_entry(value) -> dict: - """스냅샷의 seo(services/seo_keywords) → payload 의 `seo`. 없으면 키 자체를 만들지 않는다. - - ★ 여기서 다시 거르지 않는다 — 이 가게 자료로 거르는 곳은 seo_keywords 한 곳이다. 모양만 확인한다. - ★ 빈 seo 를 만들지 않는다. 빈 배열은 '받았는데 비었다' 로 읽힌다(itineraries 와 같은 규칙).""" + """스냅샷의 seo(services/seo_keywords) → payload 의 `seo`.""" if not isinstance(value, dict): return {} keywords = [_text(k) for k in value.get("keywords") or [] if _text(k)] @@ -1092,10 +731,7 @@ def payload_dir() -> Path: def write_payload(payload: dict) -> str: - """payload 를 `.json` 으로 쓴다. 경로를 돌려준다. - - ★ 임시파일에 쓰고 rename 한다 — 렌더러가 디렉토리를 통째로 읽는 구조라 - 반쯤 쓰인 JSON 을 집어 빌드가 깨지는 일이 없어야 한다(rename 은 같은 파일시스템에서 원자적).""" + """payload 를 `.json` 으로 쓴다.""" directory = payload_dir() directory.mkdir(parents=True, exist_ok=True) @@ -1127,11 +763,7 @@ async def prepare_site_payload(place, snapshot: dict, site, version, links, publ async def emit_payload(place, snapshot: dict, site, version, links, publish: bool = False) -> str | None: - """payload 조립 + 파일 쓰기. 실패해도 예외를 밖으로 내보내지 않는다. - - ★ 발행 자체를 실패시키면 안 된다 — 게이트를 통과해 DB 에 남은 발행 기록은 이미 정확하고, - payload 는 그것을 화면으로 옮기는 부수 산출물이다. 디스크가 없거나 권한이 없어서 - 발행이 되돌려지는 게 더 나쁘다. 대신 경고 로그로 반드시 드러낸다.""" + """payload 조립 + 파일 쓰기.""" try: payload = await prepare_site_payload(place, snapshot, site, version, links, publish) path = write_payload(payload) diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 213e328..cabcb95 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -16,6 +16,7 @@ from common.enums import ( SiteStatus, ) from common.logger import LOG +from common.template_catalog import is_allowed from common.models.gmodel import PageParams, UserInfo from common.utils.gtime import GTime from crud.job_crud import JobQueue @@ -45,7 +46,7 @@ from router.v1.site.protocol import ( from services import render_report, site_payload, site_slug from services.job_service import enqueue_job -# 주소 저장을 거부할 때 쓰는 결과 코드. 사유(reason)는 응답에 따로 실어 프론트가 문구를 고르게 한다. +# 주소 저장을 거부할 때 쓰는 결과 코드. _ERROR_BY_SLUG_REASON = { site_slug.REASON_LENGTH: ErrorType.INVALID_REQUEST_DATA, site_slug.REASON_FORMAT: ErrorType.INVALID_REQUEST_DATA, @@ -54,27 +55,13 @@ _ERROR_BY_SLUG_REASON = { site_slug.REASON_LOCKED: ErrorType.SITE_SLUG_LOCKED, } -# 템플릿 키 길이 상한. sites.template_id 가 varchar(100) 이라 여기서 막지 않으면 DB 가 트랜잭션째로 튕긴다. -# ★ 값 자체(무슨 템플릿인가)는 검증하지 않는다 — 목록은 프론트가 소유한다(protocol.Req_SiteTemplate 주석). -_TEMPLATE_ID_MAX = 100 - # 테마 JSON 직렬화 크기 상한(64KB). -# ★ 왜 상한만 두는가: 테마의 내용(섹션 id·배리에이션 키·색 토큰)은 프론트가 소유하므로 서버가 -# 해석하지 않는다. 해석하지 않는다는 건 무엇이든 들어올 수 있다는 뜻이라, 크기까지 놓으면 -# jsonb 컬럼 하나가 DB 를 부풀리고 그대로 site_versions.snapshot 을 타고 빌드까지 번진다. -# ★ 왜 64KB 인가: 섹션은 업종당 10개 남짓이고 한 섹션이 200바이트를 넘기 어렵다 — 실제 테마는 3KB 안쪽이다. -# 64KB 면 정상 값의 20배쯤 되는 여유라 프론트가 항목을 늘려도 걸리지 않고, -# 그걸 넘는 건 '디자인 설정'이 아니라 다른 것이 흘러든 것이다. -# ★ 필드 개수가 아니라 직렬화 바이트로 잰다. 개수로 재면 값 하나가 긴 경우를 못 막는다. _THEME_MAX_BYTES = 64 * 1024 -# ★ theme 안에 들어와도 저장하지 않는 키. sites.template_id 컬럼이 소유하는 값이라 -# theme 에 같이 담기면 어느 쪽이 진짜인지 갈리고, payload 가 읽는 쪽과 화면이 보는 쪽이 어긋난다. -# 화이트리스트가 아니라 '이 한 개만 뺀다'는 블랙리스트다 — 나머지는 전부 그대로 보관한다. +# theme 안에 들어와도 저장하지 않는 키. _THEME_OWNED_ELSEWHERE = ("templateId", "template_id") -# '거부' 표식. None 은 '빈 값 → NULL 로 되돌린다'는 정상 결과라서, 거부를 None 으로 표현하면 -# 잘못된 요청이 조용히 '디자인 초기화'로 처리된다 — 둘을 반드시 구분한다. +# '거부' 표식. _THEME_REJECTED = object() # 상태 전이만으로 처리하는 액션 — ★ 물리 삭제 경로는 만들지 않는다. @@ -86,10 +73,7 @@ _STATUS_BY_ACTION = { class SiteService: - """사이트 조회 · 빌드 트리거 · 발행 상태 전이. - - 빌드와 발행 판정은 BUILD 잡(services/build_service)이 한다 — 여기는 조회와 상태 전이만. - """ + """사이트 조회 · 빌드 트리거 · 발행 상태 전이.""" def __init__( self, @@ -141,10 +125,7 @@ class SiteService: if v_err != ErrorType.SUCCESS: version = None - # ★ AI 노출 점검 이력은 읽지 않는다. `place_ai_checks` 는 한 번도 쓰지 않아 - # 마이그레이션 0006 이 뗐다(JobType.AI_CHECK 도 아직 미배선이다). - # 여기서 그 표를 계속 부르면 SEO 진단이 통째로 죽는다 — 지금 그랬다. - # reports 모듈이 붙는 날 표와 함께 되살린다. + # AI 노출 점검 이력은 읽지 않는다. report = evaluate( await build_snapshot(place), verified=place.verified_at is not None, site=site, version=version, ai_checks=[], @@ -152,21 +133,7 @@ class SiteService: return Res_SeoAudit(**report) async def preview_payload(self, user_info: UserInfo, place_id: str) -> dict | None: - """에디터 미리보기가 쓰는 **발행본과 똑같은 payload**. DB 도 파일도 건드리지 않는다. - - ★ 왜 필요한가 (2026-09-09) - 미리보기와 발행본이 **렌더러를 두 벌** 쓰고 있었다 — 캔버스는 - `frontend/features/builder/canvas/variants/*`, 발행본은 `site/src/sections/*`. - 둘이 공유하는 건 타입과 CSS 토큰뿐이라 같은 데이터로도 다른 그림이 나왔다. - 실측(2026-09-09): 캔버스는 소개 섹션을 **설명 문구를 자리표시로** 그리는데 - 발행본은 데이터가 0자면 섹션째 뺀다 — 사장님은 채워진 화면을 보고 발행해 - 절반이 사라진 페이지를 받는다. `shared/lib/section-data.ts` 가 경고해 둔 - "빌더에서는 보이는데 발행하면 없다"가 파서가 아니라 **렌더러**에서 났다. - - ★ 그래서 미리보기도 이 payload 하나만 먹는다. 발행이 굽는 것과 같은 함수 - (`snapshot.build_snapshot` → `site_payload.prepare_site_payload`)를 그대로 거치므로, - 여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 — 화면에 안 쓰인다. - """ + """에디터 미리보기가 쓰는 **발행본과 똑같은 payload**.""" from services.build_service import ensure_site, load_channel_links from services.site_payload import prepare_site_payload from services.snapshot import build_snapshot @@ -178,12 +145,10 @@ class SiteService: site = await ensure_site(place_id) links = await load_channel_links(place_id) snapshot = await build_snapshot(place) - # ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은 - # 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다). + # version 은 None 이다. return await prepare_site_payload(place, snapshot, site, None, links) - # ---- 사이트 주소(네임스페이스) --------------------------------------- - # 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다. + # ---- 사이트 주소(네임스페이스) --------------------------------------- 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. async def _domain_owner(self, slug: str): """이 주소를 이미 쓰는 사업장 id. 아무도 안 쓰면 None.""" @@ -207,36 +172,26 @@ class SiteService: return next((c for c in candidates if c not in taken), None) async def _judge_slug(self, place_id: str, slug: str): - """(불가 사유, 대안)을 돌려준다. 쓸 수 있으면 (None, None). - - ★ 확인(check)과 저장(POST)이 이 하나를 같이 쓴다 — 판정이 갈리면 - "된다고 해놓고 저장에서 튕기는" 화면이 나온다.""" + """(불가 사유, 대안)을 돌려준다.""" reason = site_slug.validate_slug(slug) if reason in (site_slug.REASON_LENGTH, site_slug.REASON_FORMAT): - # 형식이 깨진 값에 -2 를 붙여 봐야 여전히 못 쓴다. 사유만 돌려준다. + # 형식이 깨진 값에 -2 를 붙여 봐야 여전히 못 쓴다. return reason, None value = (slug or "").strip() - # ★ 주인 확인이 예약어보다 먼저다. 예약어 목록은 나중에 늘어나는데(목업 슬러그를 막는 - # 것처럼), 늘린 이름을 이미 쓰고 있던 사장님이 자기 편집 화면을 여는 순간 - # "이 주소는 못 씁니다" 가 뜬다 — 바꿀 수도 없는 주소다(발행 뒤 SITE_SLUG_LOCKED). + # 주인 확인이 예약어보다 먼저다. owner = await self._domain_owner(value) if owner is not None and owner == str(uuid.UUID(place_id)): return None, None if reason is None: - # ★ 이미 자기 주소면 쓸 수 있다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다. + # 이미 자기 주소면 쓸 수 있다 — 저장해 둔 화면을 다시 열었을 때 '중복'이라고 하면 안 된다. if owner is None: return None, None reason = site_slug.REASON_TAKEN return reason, await self._suggest(value) async def check_slug(self, user_info: UserInfo, place_id: str, slug: str) -> Res_SlugCheck: - """주소를 쓸 수 있는지 미리 본다. - - ★ 서버가 상호명으로 자동 확정하지 않는다 — 사람이 고르고, 겹치면 고르기 전에 알려 준다. - ★ 확인과 저장은 같은 판정을 해야 한다. 발행 잠금(SITE_SLUG_LOCKED)을 여기서 안 보면 - "쓸 수 있습니다" 라고 답해 놓고 저장에서 1707 로 튕긴다 — 지금은 발행 모달이 - 입력칸을 잠가 가려져 있을 뿐이고, API 를 직접 쓰는 쪽에는 그대로 드러난다.""" + """주소를 쓸 수 있는지 미리 본다.""" res = Res_SlugCheck() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -272,10 +227,7 @@ class SiteService: value = (req.slug or "").strip() site = await self._get_site(place_id) - # ★ 이미 발행된 사이트의 주소는 바꾸지 않는다. - # 색인된 주소가 바뀌면 AI 검색이 잡아 둔 페이지가 404 가 되고, 그 자리를 다시 OTA 가 채운다 — - # '해지는 상태 전이지 삭제가 아니다' 와 정확히 같은 이유다. - # 한 번이라도 발행된 적 있으면(published_at) 중지·내림 상태여도 같다. 색인은 그 주소로 남아 있다. + # 이미 발행된 사이트의 주소는 바꾸지 않는다. if site is not None and (site.published_at is not None or site.status == SiteStatus.PUBLISHED.value): if (site.domain or "") != value: res.result.SetResult(ErrorType.SITE_SLUG_LOCKED) @@ -285,7 +237,7 @@ class SiteService: res.site = SiteData.model_validate(site) return res - # ★ 클라이언트 검증을 믿지 않는다. check 와 같은 규칙으로 서버가 다시 본다. + # 클라이언트 검증을 믿지 않는다. reason, suggestion = await self._judge_slug(place_id, value) if reason is not None: res.result.SetResult(_ERROR_BY_SLUG_REASON.get(reason, ErrorType.INVALID_REQUEST_DATA)) @@ -320,9 +272,7 @@ class SiteService: # ---- 템플릿(디자인) --------------------------------------------------- async def _mark_content_updated(self, place_id: str, ts): - """★ 발행본과 달라졌다 — 이 사업장만 다시 빌드하면 된다는 표시. - fact/faq 가 노출값이 바뀔 때 찍는 것과 같은 자리를 같은 이유로 쓴다(services/faq_service). - 부가 효과라 실패해도 본 흐름을 막지 않는다(다음 변경 때 다시 찍힌다).""" + """발행본과 달라졌다 — 이 사업장만 다시 빌드하면 된다는 표시.""" err = await DB_SESSION_MNG.execute_lambda_run([places.DBType()], [lambda s: self._touch(s, place_id, ts)]) if err != ErrorType.SUCCESS: LOG.e_no_callstack(f"[site] content_updated_at 갱신 실패 place={place_id}") @@ -334,12 +284,7 @@ class SiteService: return await DB_SESSION_MNG.add(s, query) async def set_template(self, user_info: UserInfo, place_id: str, req: Req_SiteTemplate) -> Res_Site: - """사장님이 고른 템플릿을 sites.template_id 에 저장한다. 사이트 행이 없으면 만든다. - - ★ 왜 서버에 저장하나: 위저드가 고른 값이 브라우저에만 남으면 발행 잡이 읽을 곳이 없어 - 업종 기본 템플릿으로 굽는다 — 고른 디자인과 실제 발행본이 갈린다. - ★ 슬러그와 달리 발행 뒤에도 바꿀 수 있다. 주소는 AI 검색이 색인한 영구 식별자라 잠그지만, - 디자인은 바뀌어도 URL 이 그대로다(색인이 깨지지 않는다).""" + """사장님이 고른 템플릿을 sites.template_id에 저장한다.""" # 사이트 생성은 빌드와 같은 경로를 쓴다(사업장당 1개 보장) — 지역 import 로 빌드 스택을 웹에 얹지 않는다. from services.build_service import BuildAborted, ensure_site @@ -350,8 +295,7 @@ class SiteService: return res value = (req.template_id or "").strip() - # 길이만 본다. 모르는 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨리므로 화면이 깨지지 않는다. - if len(value) > _TEMPLATE_ID_MAX: + if value and not is_allowed(int(_place.category), value): res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res @@ -366,8 +310,7 @@ class SiteService: return res before = site.template_id or None - # ★ 빈 문자열은 '고르지 않음'이다 — NULL 로 되돌려 업종 기본 템플릿으로 떨어뜨린다. - # 빈 문자열을 그대로 두면 payload 가 '있는데 이름이 없는 템플릿'을 만나 기본값으로도 못 간다. + # 빈 문자열은 '고르지 않음'이다 — NULL 로 되돌려 업종 기본 템플릿으로 떨어뜨린다. after = value or None u_err, _rowcount = await DB_SESSION_MNG.execute_lambda_claim( sites.DBType(), lambda s: self.crud.update_site(s, site.site_id, {"template_id": after}) @@ -376,8 +319,7 @@ class SiteService: res.result.SetResult(u_err) return res - # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — - # 재빌드 대상으로 표시한다. 값이 그대로면 찍지 않는다(재전송이 재빌드를 만들지 않게). + # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — 재빌드 대상으로 표시한다. if before != after and (site.published_at is not None or site.current_version_id is not None): await self._mark_content_updated(place_id, GTime.UTC()) @@ -386,17 +328,7 @@ class SiteService: # ---- 테마(색·서체·섹션) ----------------------------------------------- async def set_theme(self, user_info: UserInfo, place_id: str, req: Req_SiteTheme) -> Res_Site: - """에디터가 정한 디자인을 sites.theme 에 저장한다. 사이트 행이 없으면 만든다. - - ★ 왜 서버에 저장하나: template_id 와 정확히 같은 이유다. 섹션 on/off·순서·배리에이션·색·서체가 - 브라우저 메모리에만 있으면 새로고침에 사라지고, 발행 잡은 읽을 곳이 없어 업종 기본 모양을 굽는다 — - 사장님이 섹션을 끄고 순서를 바꿔도 발행본은 언제나 업종 기본으로 나갔다. - - ★ 값은 해석하지 않는다. 섹션 목록·배리에이션 키·색 토큰 이름은 프론트가 소유하므로 - 검증하면 프론트에 항목이 하나 늘 때마다 백엔드를 같이 고쳐야 한다(protocol.Req_SiteTheme). - 막는 것은 크기 하나뿐이다. - - ★ 템플릿과 마찬가지로 발행 뒤에도 바꿀 수 있다 — 디자인이 바뀌어도 URL 은 그대로라 색인이 안 깨진다.""" + """에디터가 정한 디자인을 sites.theme에 저장한다.""" # 사이트 생성은 빌드와 같은 경로를 쓴다(사업장당 1개 보장) — 지역 import 로 빌드 스택을 웹에 얹지 않는다. from services.build_service import BuildAborted, ensure_site @@ -429,8 +361,7 @@ class SiteService: res.result.SetResult(u_err) return res - # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — - # 재빌드 대상으로 표시한다. 값이 그대로면 찍지 않는다(재전송이 재빌드를 만들지 않게). + # 한 번이라도 구워진 사이트에서 디자인이 바뀌었다면 지금 나가 있는 페이지와 달라졌다 — 재빌드 대상으로 표시한다. if before != after and (site.published_at is not None or site.current_version_id is not None): await self._mark_content_updated(place_id, GTime.UTC()) @@ -438,14 +369,7 @@ class SiteService: @staticmethod def _clean_theme(theme): - """저장할 theme 값을 만든다. 거부해야 하면 _THEME_REJECTED 를 돌려준다. - - ★ 하는 일이 셋뿐인 게 의도다 — 서버는 이 값을 해석하지 않는다. - 1) 빈 값이면 NULL 로 되돌린다. 빈 dict 를 그대로 두면 payload 가 '있는데 아무것도 없는 테마'를 - 만나 업종 기본으로도 못 간다(template_id 의 빈 문자열과 같은 함정이다). - 2) templateId 는 뺀다 — sites.template_id 컬럼이 소유하는 값이다(중복 보관 금지). - 3) 직렬화 크기 상한을 넘으면 거부한다. - 그 외의 키(colorPaletteId 처럼 에디터 복원 전용인 것 포함)는 전부 그대로 보관한다.""" + """저장할 theme 값을 만든다.""" if not isinstance(theme, dict) or not theme: return None @@ -456,17 +380,13 @@ class SiteService: try: size = len(json.dumps(cleaned, ensure_ascii=False).encode("utf-8")) except (TypeError, ValueError): - # jsonb 컬럼에 넣을 수 없는 값이 섞였다는 뜻이다. DB 가 트랜잭션째로 튕기기 전에 여기서 막는다. return _THEME_REJECTED if size > _THEME_MAX_BYTES: return _THEME_REJECTED return cleaned async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites: - """로그인한 사장님이 가진 사이트 전부. - - 사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라 - '발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다.""" + """로그인한 사장님이 가진 사이트 전부.""" res = Res_MySites(page=pg.page, size=pg.size) uid = uuid.UUID(user_info.user_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( @@ -486,13 +406,9 @@ class SiteService: @staticmethod def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData: - # ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다. + # 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다. changed = place.content_updated_at - # ★ sites.thumbnail_url 은 발행할 때 Azure 에 대표 사진을 재호스팅해야 채워진다 - # (services/site_thumbnail.store) — 저장소가 안 꺼져 있으면(로컬 개발 등) 늘 비어 있다. - # 그래도 "한 번이라도 발행한 줄은 그림"이라는 화면 규칙은 지켜야 하므로, 빌더가 이미 - # 쓰고 있는 대표 사진(place_photos, primary_photo_url)으로 대신 채운다 — 발행 전 줄에는 - # 쓰지 않는다(그 규칙은 published_at 유무로 가른다: crud.site_crud.list_owner_sites). + # sites.thumbnail_url 은 발행할 때 Azure 에 대표 사진을 재호스팅해야 채워진다 (services/site_thumbnail.store) — 저장소가 안 꺼져 있으면(로컬 개발 등) 늘 비어 있다. ever_published = site is not None and getattr(site, "published_at", None) is not None thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None) return MySiteData( @@ -535,7 +451,7 @@ class SiteService: ) if v_err == ErrorType.SUCCESS and version is not None: res.current_version = SiteVersionData.model_validate(version) - # ★ 개별 재빌드 판별: 노출값이 바뀐 시각이 마지막 빌드보다 나중이면 다시 빌드해야 한다. + # 개별 재빌드 판별: 노출값이 바뀐 시각이 마지막 빌드보다 나중이면 다시 빌드해야 한다. built = version.built_at changed = place.content_updated_at res.needs_rebuild = bool(changed and (built is None or changed > built)) @@ -547,12 +463,8 @@ class SiteService: @staticmethod def _render_status(place, site, version) -> RenderStatusData: - """정적 페이지가 실제로 구워졌는지 + 두 렌더러가 어긋나지 않았는지. - - ★ 빌드(DB)와 렌더(정적 파일)는 다른 단계다. 빌드가 BUILT 라고 페이지가 있는 게 아니다. - 프리렌더가 남긴 보고서를 읽어 그 간극을 드러낸다. - ★ 버전이 맞는 보고서만 OK 로 본다 — 낡은 렌더를 현재 것처럼 답하면 안 된다.""" - # ★ payload 를 쓸 때와 같은 규칙으로 슬러그를 구한다(site_payload 가 소유). + """정적 페이지가 실제로 구워졌는지 + 두 렌더러가 어긋나지 않았는지.""" + # payload 를 쓸 때와 같은 규칙으로 슬러그를 구한다(site_payload 가 소유). slug = site_payload.publish_slug(place, site) if not slug: return RenderStatusData() @@ -577,25 +489,14 @@ class SiteService: res.result.SetResult(ErrorType.PLACE_NOT_VERIFIED) return res - # ★ publish=False 는 **이미 나가 있는 사이트를 다시 굽는** 경로다. 한 번도 발행한 적 - # 없는 사업장에는 쓸 수 없다. - # 왜 막나 — 빌드는 게이트를 판정하려고 payload 를 디스크에 쓰고, 프리렌더는 거기 - # 생긴 것을 곧바로 공개 페이지로 굽는다(sitemap · llms.txt · /s 목록 포함). - # 그래서 publish=False 인데도 페이지가 공개됐고, DB 는 DRAFT·published_at=NULL 이라 - # 빌더 화면은 "발행 전" 으로 보였다. 2차 게이트(고유 콘텐츠 · JSON-LD)는 구운 결과를 - # 봐야 판정하므로 **거부돼도 페이지는 이미 나가 있었다**(실측 2026-09-15). - # 발행 전에 보고 싶은 것은 `GET /site/preview` 가 준다 — 그쪽은 디스크를 건드리지 않는다. + # publish=False 는 **이미 나가 있는 사이트를 다시 굽는** 경로다. site = await self._get_site(place_id) if not req.publish: if site is None or site.published_at is None: res.result.SetResult(ErrorType.SITE_VERSION_NOT_FOUND) return res - # ★ 주소를 안 골랐어도 막지 않는다. 그때 쓰는 임시 슬러그(`slugify(상호명)-place_id[:8]`)에 - # 한글이 남는 것은 실수가 아니라 결정이다 — 음차하면 같은 가게가 두 주소를 갖는다 - # (`site_payload.slugify` 주석). 사람이 고르는 주소만 영문으로 제한한다. - # 발행 모달은 주소를 먼저 받게 되어 있고(PublishModal), API 로 건너뛰면 임시 주소로 - # 나간 뒤 SITE_SLUG_LOCKED 로 잠긴다는 점은 그대로다. + # 주소를 안 골랐어도 막지 않는다. job_id, created = await enqueue_job( self.queue, JobType.BUILD, @@ -609,21 +510,14 @@ class SiteService: res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING) return res - # ★ 노래는 여기서 따로 걸지 않는다. BUILD 잡이 스냅샷을 뜨기 **전에** 직접 만든다 - # (`build_service.run_build` → `song_service.ensure_song`) — 발행이 노래를 기다린다. - # 따로 걸면 먼저 구워지고 노래가 몇 분 뒤 붙는데, 그러면 사장님이 [사이트 열기] 로 - # 보는 첫 화면에 그 기능이 빠져 있다(2026-09-11 결정). + # 노래는 여기서 따로 걸지 않는다. res.job_id = uuid.UUID(job_id) res.status = JobStatus.PENDING res.created = created return res async def start_rollback(self, user_info: UserInfo, place_id: str, req: Req_Rollback) -> Res_StartBuild: - """예전 버전으로 공개 주소를 되돌리는 잡을 큐에 넣는다. - - ★ dedupe_key 를 빌드와 **같은 것**(`build:{place_id}`)을 쓴다 — 롤백 도중에 새 발행이 - 끼어들거나 그 반대가 되면 어느 쪽이 이겼는지 알 수 없는 상태가 된다. 이 사이트의 - 공개 주소를 바꾸는 작업은 항상 하나만 활성화된다.""" + """dedupe_key 를 빌드와 **같은 것**(`build:{place_id}`)을 쓴다 — 롤백 도중에 새 발행이 끼어들거나 그 반대가 되면 어느 쪽이 이겼는지 알 수 없는 상태가 된다.""" res = Res_StartBuild() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: @@ -699,10 +593,7 @@ class SiteService: return res async def change_status(self, user_info: UserInfo, place_id: str, req: Req_SiteStatus) -> Res_Site: - """발행 상태를 전이한다(중지·재개·내림). - - ★ 해지는 물리 삭제가 아니다. 색인된 페이지를 갑자기 404 로 만들면 - AI 검색이 그 자리를 다시 OTA 로 채운다 — 이 서비스가 하려던 것의 정반대가 된다.""" + """발행 상태를 전이한다(중지·재개·내림).""" res = Res_Site() err_type, _place = await self._load_place(user_info, place_id) if err_type != ErrorType.SUCCESS: diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py index 3abae3d..62c6af4 100644 --- a/solution/backend/tests/test_my_sites.py +++ b/solution/backend/tests/test_my_sites.py @@ -43,12 +43,12 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client): 기대결과: 템플릿·주소가 목록에 그대로 보인다.""" h = await auth_headers("my2") pid = await _place(client, h, "합쳐진펜션") - await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "stay-quiet-margin"}) + await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "paper"}) await client.post(f"/v1/place/{pid}/site/slug", headers=h, json={"slug": "joined-stay"}) row = (await _list(client, h))["sites"][0] assert row["site_id"] - assert row["template_id"] == "stay-quiet-margin" + assert row["template_id"] == "paper" assert row["domain"] == "joined-stay" assert row["status"] == SiteStatus.DRAFT.value @@ -107,7 +107,7 @@ async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client h = await auth_headers("my4") pid = await _place(client, h, "고친펜션") # 템플릿 저장이 사이트 행을 만든다. 그 뒤 노출값이 바뀐 것으로 표시한다. - await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "t"}) + await client.post(f"/v1/place/{pid}/site/template", headers=h, json={"template_id": "paper"}) async with db_engine.begin() as conn: await conn.execute( text("UPDATE places SET content_updated_at = now() WHERE place_id = :pid"), diff --git a/solution/backend/tests/test_site_template.py b/solution/backend/tests/test_site_template.py index fa643e8..d7c7849 100644 --- a/solution/backend/tests/test_site_template.py +++ b/solution/backend/tests/test_site_template.py @@ -1,22 +1,21 @@ -"""템플릿(디자인) 선택 저장. +"""템플릿 선택 저장. 이 경로가 절대 하면 안 되는 것: - - 고른 템플릿을 브라우저에만 두는 것 — 발행 잡이 읽을 곳이 없어 업종 기본으로 굽는다. - 사장님이 고른 디자인과 실제 발행본이 갈리는 것이 이 API 가 생긴 이유다. - - ★ 발행됐다고 템플릿을 잠그는 것 — 주소(slug)와 달리 디자인은 바뀌어도 URL 이 그대로다. - 잠글 이유가 없는 것을 잠그면 사장님이 발행 후에 디자인을 못 바꾼다. - - 바꿔놓고 재빌드 표시를 안 하는 것 — 관리 화면은 새 디자인, 나가 있는 페이지는 옛 디자인이 된다. + - 업종 허용 목록(templates.json)에 없는 템플릿을 저장하는 것. + - 발행됐다고 템플릿을 잠그는 것 — 디자인은 바뀌어도 URL이 그대로다. + - 바꿔 놓고 재빌드 표시를 안 하는 것. """ import uuid from sqlalchemy import text from common.enums import ErrorType, PlaceCategory, SiteStatus -from services.site_payload import _DEFAULT_THEME, to_site_payload +from common.template_catalog import INDUSTRIES +from services.site_payload import to_site_payload -async def _place(client, headers, name="템플릿펜션"): - r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1}) +async def _place(client, headers, name="템플릿펜션", category=1): + r = await client.post("/v1/place", headers=headers, json={"name": name, "category": category}) return r.json()["place"]["place_id"] @@ -30,97 +29,82 @@ async def _get_site(client, headers, pid): async def test_set_template_creates_site_row(auth_headers, client): - """검증: 사이트 행이 없어도 템플릿을 먼저 고를 수 있다(고르는 건 발행보다 앞선 결정이다). - 기대결과: SUCCESS + 저장된 값이 조회에도 그대로 나온다.""" + """검증: 사이트 행이 없어도 템플릿을 먼저 고를 수 있고, 조회에도 그대로 나온다.""" h = await auth_headers("tpl1") pid = await _place(client, h) - saved = await _set_template(client, h, pid, "stay-quiet-margin") + saved = await _set_template(client, h, pid, "paper") assert saved["result"]["code"] == ErrorType.SUCCESS.value - assert saved["site"]["template_id"] == "stay-quiet-margin" - - # 화면이 "지금 어느 템플릿으로 나가는지"를 조회로 다시 읽을 수 있어야 한다. - assert (await _get_site(client, h, pid))["site"]["template_id"] == "stay-quiet-margin" + assert saved["site"]["template_id"] == "paper" + assert (await _get_site(client, h, pid))["site"]["template_id"] == "paper" -async def test_unknown_template_key_is_accepted(auth_headers, client): - """검증: ★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. - 기대결과: 모르는 키도 저장된다(화이트리스트를 두면 템플릿 하나 늘릴 때마다 백엔드를 고쳐야 한다).""" +async def test_unknown_template_is_refused(auth_headers, client): + """검증: 등록되지 않은 id는 거절한다.""" h = await auth_headers("tpl2") pid = await _place(client, h) - saved = await _set_template(client, h, pid, "brand-new-template-nobody-knows") - assert saved["result"]["code"] == ErrorType.SUCCESS.value - assert saved["site"]["template_id"] == "brand-new-template-nobody-knows" - - -async def test_too_long_template_is_refused(auth_headers, client): - """검증: 길이(varchar(100))만은 막는다 — 안 막으면 DB 가 트랜잭션째로 튕긴다. - 기대결과: INVALID_REQUEST_DATA.""" - h = await auth_headers("tpl3") - pid = await _place(client, h) - - refused = await _set_template(client, h, pid, "t" * 101) + refused = await _set_template(client, h, pid, "stay-retro") assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value assert "site" not in refused +async def test_template_outside_industry_list_is_refused(auth_headers, client): + """검증: 등록된 템플릿이라도 그 업종 허용 목록에 없으면 거절한다(병원은 레트로를 못 쓴다).""" + h = await auth_headers("tpl3") + pid = await _place(client, h, name="템플릿의원", category=PlaceCategory.CLINIC.value) + + refused = await _set_template(client, h, pid, "retro") + assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value + + async def test_empty_value_clears_to_default(auth_headers, client): - """검증: 빈 값은 '고르지 않음'이다 — NULL 로 되돌아가 업종 기본으로 떨어진다. - 기대결과: 저장 후 빈 문자열을 보내면 template_id 가 응답에서 사라진다(None).""" + """검증: 빈 값은 '고르지 않음'이다 — NULL로 돌아가 업종 기본 템플릿으로 나간다.""" h = await auth_headers("tpl4") pid = await _place(client, h) - await _set_template(client, h, pid, "stay-quiet-margin") + await _set_template(client, h, pid, "paper") cleared = await _set_template(client, h, pid, "") assert cleared["result"]["code"] == ErrorType.SUCCESS.value - # RemoveNoneResponse 가 None 필드를 지운다 — 키가 없으면 NULL 이다. assert "template_id" not in cleared["site"] async def test_published_site_template_is_not_locked(auth_headers, client, db_engine): - """검증: ★ 발행된 사이트도 템플릿은 바꿀 수 있다(주소와 다르다 — URL 이 그대로라 색인이 안 깨진다). - 기대결과: SUCCESS + 재빌드 필요 표시(needs_rebuild).""" + """검증: 발행된 사이트도 템플릿을 바꿀 수 있고, 재빌드 표시가 선다.""" h = await auth_headers("tpl5") pid = await _place(client, h) - await _set_template(client, h, pid, "stay-o2o-editorial") + await _set_template(client, h, pid, "simple") - # 발행 상태를 만든다(빌드 잡을 돌리는 대신 상태만). async with db_engine.begin() as conn: await conn.execute( text("UPDATE sites SET status = :st, published_at = now() WHERE place_id = :p"), {"st": SiteStatus.PUBLISHED.value, "p": uuid.UUID(pid)}, ) - changed = await _set_template(client, h, pid, "stay-quiet-margin") + changed = await _set_template(client, h, pid, "paper") assert changed["result"]["code"] == ErrorType.SUCCESS.value - assert changed["site"]["template_id"] == "stay-quiet-margin" - # 나가 있는 페이지와 달라졌으므로 이 사업장만 다시 빌드하면 된다는 표시가 서야 한다. + assert changed["site"]["template_id"] == "paper" assert changed["needs_rebuild"] is True async def test_other_owners_place_is_blocked(auth_headers, client): - """검증: 남의 사업장의 템플릿은 바꿀 수 없다. - 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" + """검증: 남의 사업장의 템플릿은 바꿀 수 없다(PLACE_NOT_FOUND).""" h = await auth_headers("tpl6") intruder = await auth_headers("tpl7") pid = await _place(client, h) - blocked = await _set_template(client, intruder, pid, "stay-quiet-margin") + blocked = await _set_template(client, intruder, pid, "paper") assert blocked["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value def test_payload_uses_saved_template_and_falls_back(): - """검증: 발행 payload 의 theme.templateId 는 저장된 값을 쓰고, 없으면 업종 기본으로 떨어진다. - 기대결과: 저장값 → 그대로 / NULL → 업종 기본. ★ 고르지 않은 값을 고른 것처럼 굽지 않는다.""" + """검증: payload의 templateId는 저장값, 없으면 업종 기본 템플릿이다.""" place = {"place_id": uuid.uuid4(), "category": PlaceCategory.LODGING.value, "name": "스테이,머뭄"} snapshot = {"place": {"name": "스테이,머뭄", "category": PlaceCategory.LODGING.value}} version = {"version": 1} - chosen = to_site_payload(place, snapshot, {"template_id": "stay-quiet-margin"}, version, []) - assert chosen["theme"]["templateId"] == "stay-quiet-margin" + chosen = to_site_payload(place, snapshot, {"template_id": "paper"}, version, []) + assert chosen["theme"]["templateId"] == "paper" - default_id = _DEFAULT_THEME[PlaceCategory.LODGING.value]["templateId"] - assert to_site_payload(place, snapshot, {"template_id": None}, version, [])["theme"]["templateId"] == default_id - # 색·서체는 여전히 업종 기본이다(그 값들은 아직 저장되는 자리가 없다). - assert chosen["theme"]["fontStyle"] == _DEFAULT_THEME[PlaceCategory.LODGING.value]["fontStyle"] + fallback = to_site_payload(place, snapshot, {"template_id": None}, version, []) + assert fallback["theme"]["templateId"] == INDUSTRIES["stay"]["defaultTemplate"] diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py index cda7bd9..c7c1bd9 100644 --- a/solution/backend/tests/test_site_theme.py +++ b/solution/backend/tests/test_site_theme.py @@ -5,29 +5,25 @@ 사장님이 섹션을 끄고 순서를 바꿔도 발행본이 안 바뀌던 것이 이 API 가 생긴 이유다. - ★ 잠긴 섹션(SEO·필수 마크업)을 끈 채로 내보내는 것 — 끄기로도, 목록에서 빼기로도 막아야 한다. 한쪽만 막으면 "끄는 대신 빼면 그만"이라 잠금이 무의미해진다. - - 저장값을 서버가 해석하는 것 — 섹션 목록·배리에이션 키는 프론트가 소유한다. - 화이트리스트를 두면 프론트에 항목 하나 늘 때마다 백엔드를 같이 고쳐야 한다. + - 템플릿 정의(templates.json)에 없는 모양·템플릿으로 굽는 것. - templateId 를 theme 안에 같이 보관하는 것 — sites.template_id 와 갈린다. """ import uuid import pytest -import pathlib -import re - from sqlalchemy import text from common.enums import ErrorType, PlaceCategory, SiteStatus -from services.site_payload import _DEFAULT_THEME, to_site_payload +from common.template_catalog import INDUSTRIES, TEMPLATES, UnknownTemplate +from services.site_payload import to_site_payload # 계약 그대로의 최소 테마. 배열 순서가 곧 섹션 순서다(별도 order 필드가 없다). _THEME = { "colors": {"accent": "#c2410c"}, - "fontStyle": "Warm Serif", "colorPaletteId": "warm-sand", "sections": [ - {"id": "faq", "name": "자주 묻는 질문", "enabled": True, "locked": False, "variantId": "faq.two-column"}, + {"id": "faq", "name": "자주 묻는 질문", "enabled": True, "locked": False}, {"id": "hero", "name": "우리 히어로", "enabled": True, "locked": True}, {"id": "rooms", "name": "객실 안내", "enabled": False, "locked": False}, ], @@ -61,17 +57,15 @@ async def test_set_theme_creates_site_row_and_round_trips(auth_headers, client): assert (await _get_site(client, h, pid))["site"]["theme"] == _THEME -async def test_unknown_sections_and_variants_are_accepted(auth_headers, client): - """검증: ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키는 프론트가 소유한다. - 기대결과: 백엔드가 처음 보는 섹션 id 와 배리에이션 키도 그대로 저장된다.""" +async def test_unknown_sections_are_accepted(auth_headers, client): + """검증: 빌더가 새로 추가한 섹션 id도 그대로 저장된다.""" h = await auth_headers("thm2") pid = await _place(client, h) - theme = {"sections": [{"id": "brand-new-section", "name": "신규", "enabled": True, - "locked": False, "variantId": "nobody.knows"}]} + theme = {"sections": [{"id": "brand-new-section", "name": "신규", "enabled": True, "locked": False}]} saved = await _set_theme(client, h, pid, theme) assert saved["result"]["code"] == ErrorType.SUCCESS.value - assert saved["site"]["theme"]["sections"][0]["variantId"] == "nobody.knows" + assert saved["site"]["theme"]["sections"][0]["id"] == "brand-new-section" async def test_oversized_theme_is_refused(auth_headers, client): @@ -80,7 +74,7 @@ async def test_oversized_theme_is_refused(auth_headers, client): h = await auth_headers("thm3") pid = await _place(client, h) - refused = await _set_theme(client, h, pid, {"fontStyle": "x" * 70_000}) + refused = await _set_theme(client, h, pid, {"colors": {"accent": "x" * 70_000}}) assert refused["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value assert "site" not in refused @@ -91,10 +85,10 @@ async def test_template_id_is_not_stored_inside_theme(auth_headers, client): h = await auth_headers("thm4") pid = await _place(client, h) - saved = await _set_theme(client, h, pid, {"templateId": "sneaky", "fontStyle": "폰트"}) + saved = await _set_theme(client, h, pid, {"templateId": "sneaky", "colors": {"accent": "#000000"}}) assert saved["result"]["code"] == ErrorType.SUCCESS.value assert "templateId" not in saved["site"]["theme"] - assert saved["site"]["theme"]["fontStyle"] == "폰트" + assert saved["site"]["theme"]["colors"] == {"accent": "#000000"} async def test_empty_theme_clears_to_default(auth_headers, client): @@ -123,7 +117,7 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin {"st": SiteStatus.PUBLISHED.value, "p": uuid.UUID(pid)}, ) - changed = await _set_theme(client, h, pid, {**_THEME, "fontStyle": "다른서체"}) + changed = await _set_theme(client, h, pid, {**_THEME, "colors": {"accent": "#111111"}}) assert changed["result"]["code"] == ErrorType.SUCCESS.value # 나가 있는 페이지와 달라졌으므로 이 사업장만 다시 빌드하면 된다는 표시가 서야 한다. assert changed["needs_rebuild"] is True @@ -149,17 +143,16 @@ def _payload_theme(theme): return to_site_payload(_PLACE, _SNAPSHOT, {"template_id": None, "theme": theme}, _VERSION, [])["theme"] -def test_payload_without_saved_theme_falls_back_to_category_default(): - """검증: 저장된 디자인이 없을 때. - 기대결과: 업종 기본 색·서체·섹션 그대로 — 고르지 않은 값을 고른 것처럼 굽지 않는다.""" - spec = _DEFAULT_THEME[PlaceCategory.LODGING.value] +def test_payload_without_saved_theme_uses_industry_default(): + """검증: 저장된 디자인이 없으면 업종 기본 템플릿(숙박=retro)과 업종 섹션 목록 그대로 나간다.""" theme = _payload_theme(None) + stay = INDUSTRIES["stay"] - assert theme["fontStyle"] == spec["fontStyle"] - assert theme["colors"] == spec["colors"] - assert [s["id"] for s in theme["sections"]] == [sid for sid, _, _ in spec["sections"]] - assert all(s["enabled"] for s in theme["sections"] if s["id"] != "social") - assert next(s for s in theme["sections"] if s["id"] == "social")["enabled"] is False + assert theme["templateId"] == "retro" + assert theme["colors"] == TEMPLATES["retro"]["colors"] + assert theme["look"] == TEMPLATES["retro"]["look"] + assert [s["id"] for s in theme["sections"]] == [s["id"] for s in stay["sections"]] + assert [s["enabled"] for s in theme["sections"]] == [s["enabled"] for s in stay["sections"]] def test_payload_follows_saved_order_and_toggles(): @@ -171,13 +164,17 @@ def test_payload_follows_saved_order_and_toggles(): assert next(s for s in theme["sections"] if s["id"] == "rooms")["enabled"] is False -def test_payload_carries_variant_id_only_when_chosen(): - """검증: 배리에이션은 그대로 싣되, 고르지 않았으면 키 자체를 붙이지 않는다. - 기대결과: 고른 섹션엔 variantId, 안 고른 섹션엔 키 없음 - (null 을 실으면 렌더러 타입과 안 맞고, 비면 렌더러가 기본 레이아웃으로 떨어진다).""" - theme = _payload_theme(_THEME) - assert next(s for s in theme["sections"] if s["id"] == "faq")["variantId"] == "faq.two-column" - assert "variantId" not in next(s for s in theme["sections"] if s["id"] == "hero") +def test_payload_drops_variant_id(): + """검증: 배치 고르기를 뺐으므로 저장값에 variantId가 남아 있어도 payload에 싣지 않는다.""" + theme = _payload_theme({"sections": [{"id": "faq", "name": "FAQ", "enabled": True, "locked": False, + "variantId": "faq.two-column"}]}) + assert "variantId" not in next(s for s in theme["sections"] if s["id"] == "faq") + + +def test_payload_ignores_saved_look(): + """검증: 모양(look)은 템플릿 정의가 정한다. 저장값의 look은 무시한다.""" + theme = _payload_theme({"look": {"fontHeading": "Comic Sans"}}) + assert theme["look"] == TEMPLATES["retro"]["look"] def test_locked_section_cannot_be_published_disabled(): @@ -237,13 +234,11 @@ def test_owner_written_section_body_reaches_publish_payload(): def test_partial_colors_are_filled_from_category_default(): """검증: 저장된 색이 일부 키만 담고 있을 때. - 기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로 - 일부만 실으면 나머지 색이 undefined 로 나가 화면이 깨진다.""" - spec = _DEFAULT_THEME[PlaceCategory.LODGING.value] + 기대결과: 빠진 자리는 템플릿 색이 메운다(렌더러는 6개를 모두 요구한다).""" colors = _payload_theme({"colors": {"accent": "#c2410c"}})["colors"] assert colors["accent"] == "#c2410c" - assert colors["bg"] == spec["colors"]["bg"] - assert set(colors) == set(spec["colors"]) + assert colors["bg"] == TEMPLATES["retro"]["colors"]["bg"] + assert set(colors) == set(TEMPLATES["retro"]["colors"]) def test_color_palette_id_never_reaches_the_payload(): @@ -254,57 +249,17 @@ def test_color_palette_id_never_reaches_the_payload(): def test_saved_theme_does_not_override_chosen_template(): - """검증: templateId 의 출처. - 기대결과: 언제나 sites.template_id 다 — theme 는 색·서체·섹션만 담당한다.""" - payload = to_site_payload( - _PLACE, _SNAPSHOT, {"template_id": "stay-quiet-margin", "theme": _THEME}, _VERSION, [] - ) - assert payload["theme"]["templateId"] == "stay-quiet-margin" + """검증: templateId와 모양은 언제나 sites.template_id가 가리키는 템플릿에서 온다.""" + payload = to_site_payload(_PLACE, _SNAPSHOT, {"template_id": "paper", "theme": _THEME}, _VERSION, []) + assert payload["theme"]["templateId"] == "paper" + assert payload["theme"]["look"] == TEMPLATES["paper"]["look"] -# ── 에디터 기본 섹션표와의 1:1 대조 ──────────────────────────────────────── -# ★ 이 두 표가 어긋나면 발행본에서 섹션이 통째로 사라진다. 저장된 테마가 없는 사업장 -# (위저드만 돌고 [디자인] 탭을 건드리지 않은 대부분)은 _DEFAULT_THEME 이 곧 발행본이라, -# 여기에 없는 섹션은 사장님이 에디터에서 아무리 봐도 사이트에 나오지 않는다. -# 실측으로 날씨·실시간 예약·대관 문의·관람 안내가 그렇게 빠져 있었다. -_ADMIN_SECTIONS_TS = ( - pathlib.Path(__file__).resolve().parents[2] / "frontend/src/data/industryData.ts" # parents[2] = solution/ -) - -# 에디터 업종 키 → PlaceCategory. 이름이 다른 건 두 층의 어휘가 달라서다(clinic vs CLINIC). -_INDUSTRY_TO_CATEGORY = { - "stay": PlaceCategory.LODGING.value, - "cafe": PlaceCategory.CAFE.value, - "restaurant": PlaceCategory.RESTAURANT.value, - "clinic": PlaceCategory.CLINIC.value, -} - - -def _editor_sections() -> dict[str, list[tuple[str, str, bool]]]: - """industryData.ts 의 업종별 sections 를 (id, name, locked) 목록으로 읽는다. - - ★ TS 를 정규식으로 읽는 건 곱지 않지만, 이 표를 백엔드로 복사해 오면 복사본이 또 어긋난다. - 원본을 그대로 읽어 비교하는 것이 이 테스트의 요점이다.""" - src = _ADMIN_SECTIONS_TS.read_text(encoding="utf-8") - out: dict[str, list[tuple[str, str, bool]]] = {} - for block in re.finditer(r"^ (\w+): \{$(.*?)^ \},$", src, re.S | re.M): - industry = block.group(1) - body = block.group(2) - arr = re.search(r"^ sections: \[$(.*?)^ \],$", body, re.S | re.M) - if not arr: - continue - items = re.findall( - r"id: '([\w]+)', type: '[\w]+', name: '([^']*)', isLocked: (true|false)", arr.group(1) - ) - out[industry] = [(sid, name, locked == "true") for sid, name, locked in items] - return out - - -@pytest.mark.parametrize("industry", sorted(_INDUSTRY_TO_CATEGORY)) -def test_default_sections_match_the_editor(industry): - """검증: 업종 기본 섹션표가 에디터(industryData.ts)와 id·순서·이름·잠금까지 같은가. - 기대결과: 완전히 같다 — 여기가 어긋나면 에디터에는 보이는 섹션이 발행본에 없다.""" - editor = _editor_sections() - assert industry in editor, f"industryData.ts 에서 {industry} 의 sections 를 읽지 못했다" - server = _DEFAULT_THEME[_INDUSTRY_TO_CATEGORY[industry]]["sections"] - assert [tuple(s) for s in server] == editor[industry] +def test_unknown_template_stops_the_payload(): + """검증: 등록되지 않은 id, 업종 허용 목록에 없는 id는 payload를 만들지 않는다.""" + with pytest.raises(UnknownTemplate): + to_site_payload(_PLACE, _SNAPSHOT, {"template_id": "stay-retro"}, _VERSION, []) + clinic = {**_PLACE, "category": PlaceCategory.CLINIC.value} + clinic_snapshot = {"place": {"name": "테마의원", "category": PlaceCategory.CLINIC.value}} + with pytest.raises(UnknownTemplate): + to_site_payload(clinic, clinic_snapshot, {"template_id": "retro"}, _VERSION, []) diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 571c00b..83d3027 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -1,334 +1,25 @@ -import type {IndustryData, IndustryType, TemplateItem} from '@o2o/shared'; -/** - * 템플릿 세 벌. - * - * ★ 예전엔 업종마다 다섯 벌이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다 — - * 골라도 뭐가 달라지는지 알 수 없었다. 지금은 서체·모서리·테두리·그림자·여백까지 갈린다. - * 같은 얼굴을 색만 바꿔 늘리지 않는다. - * ★ 업종이 바꾸는 건 accent 하나다. 생김새는 업종이 아니라 취향의 문제다. - */ -const LOOK = { - simple: { - fontHeading: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", - fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", - radius: '0.75rem', - borderWidth: '1px', - shadow: '0 1px 2px rgb(0 0 0 / 0.06)', - headingTracking: '-0.02em', - headingWeight: '700', - sectionSpace: '3.5rem', - }, - magazine: { - fontHeading: "'Noto Serif KR', 'Batang', 'Times New Roman', serif", - fontBody: "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", - radius: '0px', - borderWidth: '1px', - shadow: 'none', - headingTracking: '-0.03em', - headingWeight: '700', - sectionSpace: '5rem', - }, - retro: { - fontHeading: "'Gugi', 'Noto Sans KR', sans-serif", - fontBody: "'Gowun Batang', 'Noto Serif KR', serif", - radius: '0px', - borderWidth: '2px', - shadow: '4px 4px 0 rgb(27 26 21 / 0.16)', - // 간판체는 자간을 벌리면 글자가 흩어진다. 굵기도 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다. - headingTracking: '0em', - headingWeight: '400', - sectionSpace: '4rem', - /** - * 갱지 결 — 가로 3px · 세로 4px 간격의 아주 옅은 줄 두 겹. - * - * ★ 이 칸이 비어 있어서 '옛 항구'를 골라도 면이 매끈했다. 타입(`TemplateLook.texture`)에도 - * 있고 발행본이 심는 코드(`seo/head.ts`)도 있는데 **주는 쪽만 없었다** — 색과 서체는 - * 레트로인데 종이가 아니라, 인쇄물이 아니라 '갈색 웹페이지'로 보였다. - */ - 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)', - }, - 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; +import {INDUSTRIES, TEMPLATES, type IndustryData, type IndustryType, type TemplateId} from '@o2o/shared'; -/** 업종 하나의 템플릿 세 벌. accent 만 업종이 정한다. */ -function templatesFor( - industryId: IndustryType, - accent: string, - /** - * 레트로 한 벌은 업종마다 이름·설명이 다르고, **끄는 섹션과 배리에이션도 다르다.** - * ★ 공통으로 박으면 숙박에서 내린 결정(예약 안내를 안 쓴다)이 음식점의 '노포'까지 따라간다 — - * 거긴 예약·포장 안내가 그 집의 핵심이다. - */ - retro: { - name: string; - description: string; - disabledSectionTypes?: string[]; - defaultVariants?: Record; - /** - * 이 업종의 **기본 템플릿**으로 세운다 — 목록 맨 앞에 온다. - * - * ★ 순서가 곧 기본값이다. 에디터는 저장된 templateId 가 없으면 업종의 **첫 템플릿**으로 - * 그린다(`stores/builder.ts`). 그래서 "기본을 옛 항구로" 는 배열 순서를 바꾸는 일이다. - * ★ 백엔드 `site_payload._DEFAULT_THEME` 의 templateId 와 **같아야 한다.** - * 어긋나면 에디터는 옛 항구로 그리는데 발행본은 다른 템플릿으로 나간다. - */ - isDefault?: boolean; - }, -): TemplateItem[] { - const items: TemplateItem[] = [ - { - id: `${industryId}-simple`, - industryId, - name: '심플', - tone: 'info', - toneLabel: '깔끔한 기본', - description: '흰 바탕에 고딕. 읽기 쉽고 어디에도 어울립니다. 무엇을 고를지 모르겠으면 이것.', - colors: { - primary: '#18181b', - secondary: '#52525b', - bg: '#ffffff', - card: '#fafafa', - text: '#09090b', - accent, - }, - fontStyle: '고딕', - look: LOOK.simple, - }, - { - id: `${industryId}-magazine`, - industryId, - name: '매거진', - tone: 'photo', - toneLabel: '잡지 편집', - description: '명조 제목에 각진 모서리, 그림자 없이 선으로만. 사진과 글이 많을 때 품이 납니다.', - colors: { - primary: '#111111', - secondary: '#57534e', - bg: '#ffffff', - card: '#f7f6f4', - text: '#111111', - accent, - }, - fontStyle: '명조 제목', - look: LOOK.magazine, - }, - { - id: `${industryId}-retro`, - industryId, - name: retro.name, - tone: 'photo', - toneLabel: '레트로 감성', - description: retro.description, - colors: { - // 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2. - primary: '#1b1a15', - secondary: '#4c4739', - bg: '#e4dac0', - card: '#efe7d3', - text: '#1b1a15', - // 레트로의 정체성이 이 주(朱) 잉크다 — 업종 accent 로 갈아끼우지 않는다. - accent: '#bf2f1b', - }, - fontStyle: '옛 간판체', - look: LOOK.retro, - // 이 템플릿이 팔려는 게 바로 이 아이템들이다. - /** - * 이 템플릿이 데려오는 아이템. - * - * ★ 예전 값('course' · 'schedule')은 **렌더러에 없는 타입**이었다. 시안이 그 둘을 - * itinerary · event 로 갈아치웠는데 여기만 남아서, '옛 항구'를 골라도 아이템이 - * 하나도 안 붙었다(실측 2026-09-09). - * ★ 가요·인물·연표·엽서·퀴즈는 여기 넣지 않는다 — '지역 이야기'(story) 탭이 데이터가 - * 있는 것만 골라 그린다. 따로 붙이면 탭 밖에 한 번 더 선다. - */ - defaultSectionTypes: ['event', 'video', 'festival', 'itinerary', 'story'], - ...(retro.disabledSectionTypes ? {disabledSectionTypes: retro.disabledSectionTypes} : {}), - ...(retro.defaultVariants ? {defaultVariants: retro.defaultVariants} : {}), - }, - ]; - - // 기본 템플릿을 맨 앞으로. 나머지 순서는 그대로 둔다. - return retro.isDefault ? [items[2], items[0], items[1]] : items; -} - -function paperTemplate(industryId: IndustryType, description: string): TemplateItem { +function configOf(id: IndustryType): IndustryData { + const def = INDUSTRIES[id]; 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, + id, + name: def.name, + subName: def.subName, + description: def.description, + channels: def.channels, + sections: def.sections.map(({locked, enabled, ...section}) => ({...section, isLocked: locked, isEnabled: enabled})), + defaultTemplate: def.defaultTemplate, + templates: (def.templates as TemplateId[]).map((templateId) => ({id: templateId, ...TEMPLATES[templateId]})), }; } - export const INDUSTRY_CONFIGS: Record = { - stay: { - id: 'stay', - name: '숙박', - subName: '펜션 · 스테이', - description: '감성 스테이, 풀빌라, 독채 펜션, 게스트하우스', - channels: [ - { id: 'stay_platforms', name: '야놀자 · 여기어때', checked: true }, - { id: 'naver_place', name: '네이버 플레이스', checked: true }, - { id: 'instagram', name: '인스타그램', checked: true }, - ], - sections: [ - // ★ 백엔드 기본 표(`site_payload._DEFAULT_THEME`)와 id·순서·이름·잠금이 1:1 이어야 한다. - // 어긋나면 에디터에서 본 섹션이 발행본에서 통째로 사라진다. - { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '상단 메인 비주얼과 대표 문구' }, - { id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '스테이의 철학과 공간 스토리' }, - { id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' }, - { id: 'event', type: 'event', name: '소식', isLocked: false, isEnabled: true, description: '지금 하는 행사 · 공지' }, - { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' }, - { id: 'booking', type: 'booking', name: '예약 안내', isLocked: false, isEnabled: true, description: '요금 · 예약 창구 안내' }, - { id: 'video', type: 'video', name: '영상', isLocked: false, isEnabled: true, description: '유튜브 주소 하나면 됩니다' }, - { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' }, - { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' }, - { id: 'festival', type: 'festival', name: '계절별 축제', isLocked: false, isEnabled: true, description: '주변에서 열리는 축제 — 계절로 묶어 보여줍니다' }, - { id: 'local', type: 'local', name: '지역 정보', isLocked: false, isEnabled: true, description: '주변 관광지 및 맛집 추천' }, - { id: 'itinerary', type: 'itinerary', name: '추천 일정', isLocked: false, isEnabled: true, description: '숙소에서 출발하는 하루 코스' }, - { id: 'story', type: 'story', name: '지역 이야기', isLocked: false, isEnabled: true, description: '가요·인물·연표·엽서·퀴즈를 탭으로' }, - { id: 'faq', type: 'faq', 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: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' }, - ], - templates: [ - ...templatesFor('stay', '#2563eb', { - name: '옛 항구', - description: '갱지 바탕에 간판체. 도넛판·일력·승차권이 함께 들어옵니다. 오래된 항구 도시의 인상으로 묵는 곳을 소개합니다.', - // 숙박의 기본 템플릿 (2026-09-10, 사장님 지시). 백엔드 `_DEFAULT_THEME` 도 stay-retro 다. - isDefault: true, - /** 시안의 사진 갤러리는 캐러셀이다. 색·서체만 맞고 모양이 기본이면 시안이 안 된다. */ - defaultVariants: {photos: 'photos.carousel'}, - }), - paperTemplate('stay', '크림빛 종이에 가는 명조. 그림자도 장식도 없이, 백 년 된 집의 정갈함을 그대로 보여줍니다.'), - ], - }, - - cafe: { - id: 'cafe', - name: '카페', - subName: '대형카페 · 로스터리', - description: '스페셜티 로스터리, 베이커리 카페, 오션·마운틴 뷰 대형 카페', - channels: [ - { id: 'naver_place', name: '네이버 플레이스', checked: true }, - { id: 'instagram', name: '인스타그램', checked: true }, - { id: 'kakao_map', name: '카카오맵', checked: true }, - ], - sections: [ - { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '시그니처 비주얼과 카페 슬로건' }, - { id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '로스팅 철학과 공간 스토리' }, - { id: 'menu', type: 'menu', name: '시그니처 메뉴', isLocked: false, isEnabled: true, description: '대표 원두, 음료, 시그니처 디저트' }, - { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '영업시간, 좌석수, 편의시설 정보' }, - { id: 'space', type: 'space', name: '공간 · 좌석 안내', isLocked: false, isEnabled: true, description: '1/2층 공간 구성 및 야외 테라스석' }, - { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '인테리어, 커피, 베이커리 비주얼' }, - { id: 'inquiry', type: 'inquiry', name: '대관 및 단체 문의', isLocked: false, isEnabled: true, description: '촬영 대관 및 단체 예약 접수' }, - { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '드라이브 코스 및 주차 진입로 안내' }, - { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' }, - { id: 'local', type: 'local', 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: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' }, - ], - templates: [ - ...templatesFor('cafe', '#b45309', { - name: '옛 다방', - description: '갱지 바탕에 간판체. LP 와 손글씨로, 다방 시절의 인상으로 지금의 커피를 이야기합니다.', - }), - paperTemplate('cafe', '크림빛 종이에 가는 명조. 그림자도 장식도 없이, 조용한 카페의 정갈함을 그대로 보여줍니다.'), - ], - }, - - restaurant: { - id: 'restaurant', - name: '음식점', - subName: '식당 · 주점', - description: '한식 다이닝, 일식 오마카세, 이탈리안 비스트로, 고기집', - channels: [ - { id: 'naver_place', name: '네이버 플레이스', checked: true }, - { id: 'catchtable', name: '캐치테이블', checked: true }, - { id: 'instagram', name: '인스타그램', checked: true }, - ], - sections: [ - { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '대표 요리 비주얼 및 다이닝 소개' }, - { id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '셰프의 조리 철학과 식재료 원산지 이야기' }, - { id: 'menu', type: 'menu', name: '코스 및 메뉴', isLocked: false, isEnabled: true, description: '점심/저녁 코스, 단품 요리, 주류 페어링' }, - { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '영업시간, 휴무일, 주차, 예약 안내' }, - { id: 'booking', type: 'booking', name: '예약 · 포장 안내', isLocked: false, isEnabled: true, description: '캐치테이블 실시간 룸 예약 및 포장' }, - { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '플레이팅, 룸 인테리어, 정갈한 상차림' }, - { id: 'inquiry', type: 'inquiry', name: '단체 행사 문의', isLocked: false, isEnabled: true, description: '상견례, 돌잔치, 기업 대관 문의' }, - { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '지하철역 출구 및 발렛부스 위치' }, - { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 매장 주변 날씨' }, - { id: 'local', type: 'local', 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: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' }, - ], - templates: [ - ...templatesFor('restaurant', '#16a34a', { - name: '노포', - description: '갱지 바탕에 간판체. 오래 해온 집이라는 사실 자체가 메뉴판이 됩니다.', - }), - paperTemplate('restaurant', '크림빛 종이에 가는 명조. 고택에서 차리는 한 상처럼, 담백하고 단정한 인상을 남깁니다.'), - ], - }, - - clinic: { - id: 'clinic', - name: '피부과 · 성형외과', - subName: '의원 · 클리닉', - description: '피부과, 성형외과, 미용 클리닉', - channels: [ - { id: 'naver_place', name: '네이버 플레이스', checked: true }, - { id: 'kakao_channel', name: '카카오톡 채널', checked: true }, - { id: 'instagram', name: '인스타그램', checked: true }, - ], - sections: [ - { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '병원 대표 이미지와 진료 분야' }, - { id: 'intro', type: 'intro', name: '병원 소개', isLocked: false, isEnabled: true, description: '진료 철학과 의료진 소개' }, - { id: 'programs', type: 'programs', name: '시술 안내', isLocked: false, isEnabled: true, description: '시술명, 소요 시간, 비용' }, - { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '진료시간, 휴진일, 예약, 주차' }, - { id: 'exhibition', type: 'exhibition', name: '진료 안내', isLocked: false, isEnabled: true, description: '상담 절차와 보험 적용 안내' }, - { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '진료실, 상담실, 대기 공간' }, - { id: 'inquiry', type: 'inquiry', name: '상담 문의', isLocked: false, isEnabled: true, description: '방문·전화 상담 접수' }, - { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '역에서 오는 길과 주차장' }, - { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: false, description: '현재 기온과 주변 날씨' }, - { id: 'local', type: 'local', name: '주변 정보', isLocked: false, isEnabled: false, description: '주변 편의시설' }, - { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '예약 변경, 회복 기간, 주의사항' }, - { id: 'social', type: 'social', name: 'SNS 게시글', isLocked: false, isEnabled: false, description: '승인해 함께 발행한 소식 · 홈페이지 맨 아래' }, - ], - templates: templatesFor('clinic', '#4A9DC4', { - name: '클린', - description: '여백과 낮은 채도. 과장 없이 정보를 먼저 보여줍니다.', - }), - }, + stay: configOf('stay'), + cafe: configOf('cafe'), + restaurant: configOf('restaurant'), + clinic: configOf('clinic'), }; -/** - * 업종을 아직 모를 때 떨어질 자리. - * - * ★ 스토어의 초기 업종이자, 서버 category 를 못 알아봤을 때의 기본값이다(placeAdapter). - * 두 곳이 다른 값을 쓰면 "빌더가 처음 보여준 화면"과 "사업장을 열었을 때 화면"의 - * 섹션·문구가 달라진다 — 사장님 눈에는 값이 바뀐 것으로 보인다. - */ +// 업종 미확인 시 기본값(스토어 초기값 · placeAdapter 폴백, 두 곳이 같아야 함) export const FALLBACK_INDUSTRY: IndustryType = 'stay'; diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index 5335279..0c97772 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -73,8 +73,6 @@ export function CanvasView() {
{!isPreviewMode && (
- {/* ★ min-w-0 — 이 줄이 안 줄어들면 캔버스 칸 전체의 최소 너비가 올라가고, - 그 여파로 오른쪽 패널이 화면 밖으로 밀린다(EditorLayout 주석 참조). */}
미리보기 해상도:
@@ -106,9 +104,6 @@ export function CanvasView() { )}
- {/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09). - 안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다. - 바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */}
{viewport !== 'pc' && (
@@ -123,13 +118,6 @@ export function CanvasView() { {viewport.toUpperCase()}
)} - - {/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09). - 캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측: - 발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다 - (gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과 - 발행된 화면이 서로 다른 물건이었다. - 고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */} - - {/* ★ 푸터도 모바일 하단 탭바도 여기서 그리지 않는다 - (2026-09-15 대표: "에디터에서 이 부분 필요없음 / 이 부분 자체가 필요없음") - 발행본이 iframe 안에서 **둘 다 이미 그린다** — 푸터는 `site/sections/SiteFooter.tsx`, - 탭바는 `site/sections/MobileTabBar.tsx`(App 과 레이아웃 Shell 다섯이 모두 세운다). - 바깥에 한 벌 더 그리면 상호·주소·저작권·탭이 두 번 서고, 두 벌의 값이 어긋날 수도 - 있다 — 이 캔버스가 "발행되는 그 화면"이어야 한다는 원칙(SitePreview 주석)에 어긋난다. */}
diff --git a/solution/frontend/src/features/builder/ItemFormEditor.tsx b/solution/frontend/src/features/builder/ItemFormEditor.tsx index 1c49fea..c4625fe 100644 --- a/solution/frontend/src/features/builder/ItemFormEditor.tsx +++ b/solution/frontend/src/features/builder/ItemFormEditor.tsx @@ -1,26 +1,10 @@ -/** - * 붙여넣기 아이템의 **직접 입력** 폼. - * - * ★ 왜 만들었나 - * 입구가 JSON 붙여넣기 하나뿐이었다. 한 글자만 고치려 해도 사장님이 중괄호와 쉼표를 - * 헤집어야 했고, 쉼표 하나 잘못 지우면 섹션이 통째로 사라졌다. ChatGPT 를 안 쓰는 - * 사장님은 아예 채울 수가 없었다. - * - * ★ 폼과 JSON 은 한 값의 두 얼굴이다 - * 진실은 `section.data` **문자열 하나**뿐이고 이 폼은 그걸 비춘다. 그래서 - * JSON 을 붙여넣으면 폼이 따라 바뀌고, 폼을 고치면 JSON 이 다시 쓰인다 — - * 어느 쪽이 최신인지 물을 일이 없다. 폼 상태를 따로 들고 있으면 그 순간 - * "화면은 새 값, 저장은 옛 값"이 생긴다. - * - * ★ 깨진 JSON 은 폼으로 못 편다. 그때는 폼을 감추고 오류만 남긴다 — - * 반쯤 읽힌 값으로 폼을 그리면 사장님이 쓴 걸 덮어쓴다. - */ +/** 붙여넣기 아이템의 **직접 입력** 폼. */ import {Plus, Trash2} from 'lucide-react'; import {parseSectionData} from '@o2o/shared'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {cn} from '@/lib/utils'; -import type {ItemField, SectionDataSpec} from './canvas/dataSpec'; +import type {ItemField, SectionDataSpec} from './sections/dataSpec'; type Row = Record; diff --git a/solution/frontend/src/features/builder/RightTabsPanel.tsx b/solution/frontend/src/features/builder/RightTabsPanel.tsx index a5a0ac0..98fd2af 100644 --- a/solution/frontend/src/features/builder/RightTabsPanel.tsx +++ b/solution/frontend/src/features/builder/RightTabsPanel.tsx @@ -27,12 +27,11 @@ import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {FaqPanel} from './FaqPanel'; import {ItemFormEditor} from './ItemFormEditor'; import {SectionDesignPanel} from './SectionDesignPanel'; -import {resolveVariant} from './canvas/registry'; import { buildPrompt, dataSpecFor, SECTION_DATA_MAX_CHARS, -} from './canvas/dataSpec'; +} from './sections/dataSpec'; const TABS: {id: RightTab; label: string; icon: typeof Info}[] = [ {id: 'content', label: '콘텐츠', icon: FileText}, @@ -86,7 +85,6 @@ export function RightTabsPanel() { } function ContentTab() { - const industry = useBuilderStore((s) => s.industry); const sections = useBuilderStore((s) => s.sections); const selectedSectionId = useBuilderStore((s) => s.selectedSectionId); const updateSectionContent = useBuilderStore((s) => s.updateSectionContent); @@ -110,17 +108,9 @@ function ContentTab() { description: patch.description ?? section.description, body: patch.body ?? section.body, }); - const variantId = resolveVariant(section, industry)?.id ?? ''; const supportsTitle = section.type !== 'local'; - const supportsDescription = !( - variantId.startsWith('rooms.') || variantId.startsWith('rules.') || - variantId.startsWith('local.') || variantId === 'menu.price-table' || - variantId === 'space.list' || variantId === 'programs.table' || - variantId === 'exhibition.notice' - ); - const supportsBody = - variantId.startsWith('intro.') || variantId.startsWith('booking.') || - variantId === 'inquiry.cta' || variantId === 'exhibition.notice'; + const supportsDescription = !['rooms', 'rules', 'local'].includes(section.type); + const supportsBody = ['intro', 'booking', 'inquiry'].includes(section.type); return (
@@ -171,12 +161,7 @@ function ContentTab() { ); } -/** - * 붙여넣기 아이템의 JSON 입력. - * - * ★ 원문을 그대로 저장한다. 깨진 JSON 도 담아 두고, 왜 깨졌는지만 아래에 말한다 — - * 저장을 막으면 사장님은 고칠 기회 없이 쓰던 걸 잃는다. - */ +/** 붙여넣기 아이템의 JSON 입력. */ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionType: string}) { const spec = dataSpecFor(sectionType); const sections = useBuilderStore((s) => s.sections); @@ -187,12 +172,7 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT const [copied, setCopied] = useState(false); const [showPrompt, setShowPrompt] = useState(false); - /** - * 직접 입력 ↔ JSON. - * - * ★ 둘은 같은 값(`section.data`)의 두 얼굴이라 어느 쪽으로 고쳐도 다른 쪽이 따라온다. - * 기본은 **직접 입력**이다 — JSON 을 먼저 보여 주면 대부분의 사장님이 거기서 멈춘다. - */ + /** 직접 입력 ↔ JSON. */ const [mode, setMode] = useState<'form' | 'json'>('form'); const section = sections.find((item) => item.id === sectionId); if (!spec || !section) return null; @@ -201,7 +181,7 @@ function SectionDataPanel({sectionId, sectionType}: {sectionId: string; sectionT const parsed = parseSectionData(sectionType, raw); const tooLong = raw.length > SECTION_DATA_MAX_CHARS; - // 상호·주소가 이미 박혀 있는 프롬프트. 사장님이 빈칸을 채울 일이 없어야 한다. + // 상호·주소가 이미 박혀 있는 프롬프트. const promptText = buildPrompt(spec, { storeName, location, @@ -435,8 +415,7 @@ function InfoTab() { const [newLabel, setNewLabel] = useState(''); const [newValue, setNewValue] = useState(''); const placeId = useBuilderStore((s) => s.placeId); - // ★ 셀렉터 안에서 새 배열을 만들지 않는다(스토어 상단 주의사항) — state 의 배열을 그대로 받아 - // 여기서 includes 로 좁힌다. 셀렉터가 매번 새 배열을 돌려주면 무한 렌더로 죽는다. + // 셀렉터 안에서 새 배열을 만들지 않는다(스토어 상단 주의사항) — state 의 배열을 그대로 받아 여기서 includes 로 좁힌다. const savingFieldIds = useBuilderStore((s) => s.savingFieldIds); return ( @@ -521,9 +500,7 @@ function InfoTab() { 기록되어, 이후 자동 수집이 덮어쓰지 않습니다.

- {/* ★ 재수집을 [정보] 탭 아래에 둔다. 에디터에서 "이 값이 옛날 값인데"를 깨닫는 자리가 - 바로 여기라, 다시 가져오는 버튼도 같은 자리에 있어야 찾는다. - 데모(placeId 없음)에서는 패널이 스스로 사라진다. */} + {/* 재수집을 [정보] 탭 아래에 둔다. */}
diff --git a/solution/frontend/src/features/builder/SectionDesignPanel.tsx b/solution/frontend/src/features/builder/SectionDesignPanel.tsx index 67da08d..68982e7 100644 --- a/solution/frontend/src/features/builder/SectionDesignPanel.tsx +++ b/solution/frontend/src/features/builder/SectionDesignPanel.tsx @@ -1,50 +1,25 @@ -/** - * [디자인] 탭 — 고른 섹션의 레이아웃 배리에이션을 바꾸는 자리. - * - * 사장님은 코드 이름이 아니라 모양으로 고른다. 그래서 카드마다 와이어프레임을 붙이고, - * "언제 이걸 고르면 좋은지"를 한 줄로 적는다. - */ -import { - Check, - ChevronDown, - LayoutTemplate, - MousePointerClick, - Palette, - RotateCcw, - Shapes, -} from 'lucide-react'; +import {Check, ChevronDown, LayoutTemplate, Palette} from 'lucide-react'; import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {queueSiteTemplateSave} from '@/features/publish/siteTemplate'; import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; import {COLOR_PALETTE_PRESETS} from './colorPalettes'; import {TemplatePreview} from './TemplatePreview'; -import {resolveVariant, variantsFor} from './canvas/registry'; -import {VariantThumb} from './canvas/thumbs'; -/** - * 접히는 묶음. - * - * ★ [디자인] 탭은 290px 한 칸이다. 여기에 템플릿 미리보기 셋 + 팔레트 열두 칸 + - * 이 섹션의 배리에이션이 세로로 쌓이면 스크롤이 세 화면을 넘고, 정작 방금 고른 섹션의 - * 배리에이션이 맨 아래로 밀린다. 큰 것(템플릿·색)은 접어 두고 필요할 때 편다. - * ★ `
` 다 — 상태를 리액트로 들면 탭을 오갈 때마다 접힘이 초기화된다. - */ +//
라 탭을 오가도 접힘 상태가 남는다. function Group({ title, icon: Icon, count, - open, children, }: { title: string; icon: typeof Palette; count?: string; - open?: boolean; children: React.ReactNode; }) { return ( -
+
{title} @@ -56,41 +31,27 @@ function Group({ ); } -/** - * 템플릿 고르기. - * - * ★ 이 자리가 없었다. 템플릿은 온보딩 4단계에서 한 번 고르면 끝이었고, 에디터의 [디자인] 탭에는 - * 팔레트와 섹션 배리에이션만 있었다 — 사장님은 **디자인을 바꾸러 들어와서 디자인을 못 바꿨다.** - * 스토어의 `selectTemplate` 과 서버 저장(`queueSiteTemplateSave`)은 처음부터 있었고 UI 만 없었다. - * ★ 미리보기는 위저드와 **같은 컴포넌트**다(TemplatePreview). 두 벌로 그리면 고를 때 본 것과 - * 에디터에서 본 것이 갈린다. - */ -function TemplatePicker({open}: {open?: boolean}) { +function TemplatePicker() { const industry = useBuilderStore((s) => s.industry); const templateId = useBuilderStore((s) => s.templateId); const placeId = useBuilderStore((s) => s.placeId); const selectTemplate = useBuilderStore((s) => s.selectTemplate); const templates = INDUSTRY_CONFIGS[industry].templates; - /** - * ★ 저장된 templateId 가 지금 목록에 **없을 수 있다.** 실제로 있었다 — - * `stay-warm-wood` 처럼 예전 이름이 sites.template_id 에 남아 있으면 - * `resolveTemplate` 은 말없이 첫 템플릿으로 떨어지는데, 이 목록에서는 아무것도 - * 선택돼 보이지 않아 "고를 수 없는 화면"이 된다. 떨어지는 자리를 여기서도 같게 본다. - */ - const activeId = templates.some((t) => t.id === templateId) ? templateId : templates[0].id; return ( - + +

+ 템플릿을 바꾸면 이전 템플릿이 켜 둔 섹션이 꺼질 수 있습니다. 내용은 지워지지 않고, 섹션 목록에서 다시 켤 수 있습니다. +

{templates.map((template) => { - const isActive = activeId === template.id; + const isActive = templateId === template.id; return ( ); @@ -118,14 +76,14 @@ function TemplatePicker({open}: {open?: boolean}) { ); } -function ColorPalettePicker({open}: {open?: boolean}) { +function ColorPalettePicker() { const industry = useBuilderStore((s) => s.industry); const selectedId = useBuilderStore((s) => s.colorPaletteId); const selectColorPalette = useBuilderStore((s) => s.selectColorPalette); const palettes = COLOR_PALETTE_PRESETS.filter((palette) => palette.industry === industry); return ( - +
{palettes.map((palette) => { const isActive = selectedId === palette.id; @@ -167,114 +125,10 @@ function ColorPalettePicker({open}: {open?: boolean}) { } export function SectionDesignPanel() { - const industry = useBuilderStore((s) => s.industry); - const sections = useBuilderStore((s) => s.sections); - const selectedSectionId = useBuilderStore((s) => s.selectedSectionId); - const setSectionVariant = useBuilderStore((s) => s.setSectionVariant); - const resetSectionVariants = useBuilderStore((s) => s.resetSectionVariants); - - const section = sections.find((s) => s.id === selectedSectionId); - const variants = section ? variantsFor(section.type, industry) : []; - const current = section ? resolveVariant(section, industry) : undefined; - - if (!section) { - return ( -
- - -
- - - -

섹션을 먼저 고르세요

-

- 왼쪽 목록이나 미리보기에서 섹션을 누르면 -
그 섹션의 레이아웃을 바꿀 수 있습니다. -

-
-
- ); - } - return ( -
- {/* 큰 것부터 좁혀 간다 — 템플릿(전체) → 팔레트(색) → 이 섹션의 레이아웃. */} +
-
- - - {section.name} - - - {variants.length}종 - -
- - {variants.length === 0 ? ( -

- 이 섹션은 아직 고를 수 있는 레이아웃이 하나뿐입니다. -

- ) : ( -
    - {variants.map((variant) => { - const isActive = current?.id === variant.id; - - return ( -
  • - -
  • - ); - })} -
- )} - -
- - -

- 레이아웃만 바뀝니다 —{' '} - 입력한 내용과 사진, 섹션 순서는 그대로{' '} - 유지됩니다. -

-
); } diff --git a/solution/frontend/src/features/builder/SectionListPanel.tsx b/solution/frontend/src/features/builder/SectionListPanel.tsx index feb2f11..e4eb5b0 100644 --- a/solution/frontend/src/features/builder/SectionListPanel.tsx +++ b/solution/frontend/src/features/builder/SectionListPanel.tsx @@ -8,13 +8,12 @@ import { import {CSS} from '@dnd-kit/utilities'; import {useState} from 'react'; import type {SectionItem} from '@o2o/shared'; -import {Check, GripVertical, Layers, Lock, Plus, Shapes, Trash2, X} from 'lucide-react'; +import {Check, GripVertical, Layers, Lock, Plus, Trash2, X} from 'lucide-react'; import {Switch} from '@/components/ui/switch'; import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; -import {ADDABLE_SECTIONS, addableSection} from './canvas/addable'; -import {resolveVariant} from './canvas/registry'; -import {VariantThumb} from './canvas/thumbs'; +import {ADDABLE_SECTIONS, addableSection} from './sections/addable'; +import {VariantThumb} from './sections/thumbs'; interface SectionListPanelProps { onAfterSelect?: () => void; @@ -26,13 +25,11 @@ interface SortableSectionProps extends SectionListPanelProps { } function SortableSection({section, index, onAfterSelect}: SortableSectionProps) { - const industry = useBuilderStore((s) => s.industry); const setRightTab = useBuilderStore((s) => s.setRightTab); const isSelected = useBuilderStore((s) => s.selectedSectionId === section.id); const selectSection = useBuilderStore((s) => s.selectSection); const toggleSection = useBuilderStore((s) => s.toggleSection); const removeSection = useBuilderStore((s) => s.removeSection); - const variant = resolveVariant(section, industry); // 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다 — 뺄 수 있는 건 나중에 넣은 것뿐이다. const canRemove = Boolean(addableSection(section.type)); const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({ @@ -45,8 +42,7 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps) onAfterSelect?.(); if (!section.isEnabled) return; - // 좁은 화면에서는 onAfterSelect가 캔버스 탭을 먼저 연다. 다음 페인트가 끝난 뒤 - // 실제 섹션으로 이동해야 display:none 상태의 위치를 잘못 읽지 않는다. + // 좁은 화면에서는 onAfterSelect가 캔버스 탭을 먼저 연다. requestAnimationFrame(() => { requestAnimationFrame(() => { document @@ -102,32 +98,6 @@ function SortableSection({section, index, onAfterSelect}: SortableSectionProps) {section.name} - {variant && ( - { - event.stopPropagation(); - selectSection(section.id); - setRightTab('design'); - onAfterSelect?.(); - }} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - event.stopPropagation(); - selectSection(section.id); - setRightTab('design'); - onAfterSelect?.(); - }} - className="flex cursor-pointer items-center gap-0.5 truncate text-[9px] font-normal text-muted-foreground hover:text-foreground" - > - - {variant.name} - - )} - {section.isLocked && ( 필수 고정 diff --git a/solution/frontend/src/features/builder/SitePreview.tsx b/solution/frontend/src/features/builder/SitePreview.tsx index ba3edae..2cdf5a9 100644 --- a/solution/frontend/src/features/builder/SitePreview.tsx +++ b/solution/frontend/src/features/builder/SitePreview.tsx @@ -1,42 +1,21 @@ /** - * 미리보기 — 발행본을 **iframe 으로** 띄운다. + * 미리보기 — 발행본을 iframe으로 띄운다. iframe이 여는 주소는 `/preview?placeId=…` + * (site/scripts/prerender.writePreviewShell이 굽는 CSR 셸, 발행본 앱이 그 안에서 그대로 돈다). + * 같은 오리진이라 토큰(localStorage)을 iframe이 그대로 읽는다. * - * ★ 왜 iframe 인가 (2026-09-09) - * 처음엔 발행본 컴포넌트를 빌더 안에 직접 그렸다. 렌더러도 payload 도 토큰도 같게 맞췄고 - * 섹션 15개의 계산 스타일(색·서체·여백)까지 동일해졌는데, **레이아웃 폭이 어긋났다.** - * 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 그 안의 프레임이기 때문이다. - * 실측(1400px 창 · 1024px 프레임, Playwright): - * festival 2560px → 6027px · guide 1168 → 2168 · location 586 → 1135 - * 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. 전체 픽셀 차이 89%. - * "같은 렌더러를 쓴다" 만으로는 안 되고 **뷰포트가 같아야** 한다. - * - * iframe 은 자체 뷰포트를 가진다 — 폭을 390/768/1024 로 주면 발행본이 그 폭에서 보는 것과 - * 같은 미디어 쿼리가 걸린다. 해상도 전환도 그제서야 진짜가 된다. - * - * ★ iframe 이 여는 것은 `/preview?placeId=…` — 프리렌더가 굽는 CSR 셸이다 - * (`site/scripts/prerender.writePreviewShell`). 그 안에서 발행본 앱이 그대로 돈다. - * ★ 같은 오리진이라 토큰(localStorage)을 iframe 이 그대로 읽는다. 따로 넘기지 않는다. + * iframe으로 띄우는 이유: 빌더 안에 직접 그렸을 때 렌더러·payload·토큰을 다 맞춰도 + * 레이아웃 폭이 어긋났다 — 미디어쿼리는 창 폭을 보는데 실제 사이트 폭은 프레임 폭이라서다. + * iframe은 자체 뷰포트를 가지므로 폭을 390/768/1024로 주면 발행본과 같은 미디어쿼리가 걸린다. */ import {useCallback, useEffect, useRef, useState} from 'react'; import type {ViewportMode} from '@o2o/shared'; import {LoaderCircle} from 'lucide-react'; import {onSiteThemeSaved} from '@/features/publish/siteTheme'; -/** - * 미리보기가 그려지기를 기다리는 상한. 넘으면 로딩 표시를 걷고 iframe 을 그대로 보여준다. - * - * ★ 영영 안 걷히는 로딩바는 안 띄우느니만 못하다 — 구 번들이 올라가 있어 `o2o:preview-painted` - * 를 안 보내는 동안에도 편집은 돼야 한다(발행본 번들은 재발행 전까지 옛것일 수 있다). - */ +// 넘으면 로딩 표시를 걷고 iframe을 그대로 보여준다(구 번들이라 painted 신호가 안 올 수 있음). const PAINT_TIMEOUT_MS = 12000; -/** - * 각 해상도에서 iframe 에 줄 **크기**. 발행본이 그 기기에서 보는 것과 같아야 한다. - * - * ★ 높이도 준다. 폭만 맞추면 `vh` 를 쓰는 자리가 어긋난다 — 히어로가 - * `clamp(24rem, 62vh, 36rem)` 이라(`HeroSection`), iframe 이 낮으면 하한 384px 에 걸린다. - * 실측: 발행본 576px ↔ 미리보기 384px. 지연 로딩 이미지도 화면에 덜 들어와 적게 뜬다. - */ +// 해상도별 iframe 크기. 높이도 줘야 vh 쓰는 자리(히어로 등)가 발행본과 맞는다. const FRAME_SIZE: Record = { pc: {w: 1024, h: 800}, tablet: {w: 768, h: 1024}, diff --git a/solution/frontend/src/features/builder/TemplatePreview.tsx b/solution/frontend/src/features/builder/TemplatePreview.tsx index 9e6c7ec..bad678c 100644 --- a/solution/frontend/src/features/builder/TemplatePreview.tsx +++ b/solution/frontend/src/features/builder/TemplatePreview.tsx @@ -1,11 +1,6 @@ import type {TemplateItem} from '@o2o/shared'; -/** - * 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다. - * - * ★ 예전에는 회색 막대 세 줄과 색 동그라미였다. 다섯 템플릿이 전부 같은 그림이라 - * 무엇을 고르는지 알 수 없었고, 그래서 아무거나 골랐다. - */ +/** 템플릿 미리보기 — 그 템플릿의 서체·모서리·테두리·그림자로 **실제로** 그린다. */ export function TemplatePreview({template}: {template: TemplateItem}) { const {look, colors} = template; @@ -39,7 +34,7 @@ export function TemplatePreview({template}: {template: TemplateItem}) { className="mt-1.5 text-[10px] leading-relaxed" style={{fontFamily: look.fontBody, color: colors.secondary}} > - 제목은 {template.fontStyle}, 본문은 이 서체로 나갑니다. + 제목과 본문이 이 서체로 나갑니다.

= ({ - nodes, - primaryColor = '#18181b', - className = '', -}) => { - if (!nodes || nodes.length === 0) { - return null; - } - - const renderTextNode = (node: SlateTextNode, idx: number) => { - let content: React.ReactNode = node.text; - if (node.bold) content = {content}; - if (node.italic) content = {content}; - if (node.underline) content = {content}; - if (node.code) { - content = ( - - {content} - - ); - } - return {content}; - }; - - const renderElement = (node: SlateElementNode, idx: number) => { - switch (node.type) { - case 'chat-dialog': - case 'dialog': - case 'negotiation': { - const isHost = node.role === 'seller' || node.role === 'host' || node.role === 'ai'; - return ( -
- {/* Avatar */} -
- {node.speaker ? node.speaker.slice(0, 1) : isHost ? '호' : '손'} -
- - {/* Bubble */} -
-
- {node.speaker || (isHost ? '호스트' : '게스트')} - {node.time && · {node.time}} -
-
- {node.children.map((child, cIdx) => - 'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx) - )} -
-
-
- ); - } - - case 'heading-two': - case 'h2': - return ( -

- {node.children.map((child, cIdx) => - 'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx) - )} -

- ); - - case 'quote': - case 'blockquote': - return ( -
- {node.children.map((child, cIdx) => - 'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx) - )} -
- ); - - case 'callout': - case 'info-box': - return ( -
- {node.children.map((child, cIdx) => - 'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx) - )} -
- ); - - default: - return ( -

- {node.children.map((child, cIdx) => - 'text' in child ? renderTextNode(child, cIdx) : renderElement(child, cIdx) - )} -

- ); - } - }; - - return ( -
- {nodes.map((node, idx) => - 'text' in node ? renderTextNode(node, idx) : renderElement(node, idx) - )} -
- ); -}; diff --git a/solution/frontend/src/features/builder/canvas/addable.ts b/solution/frontend/src/features/builder/canvas/addable.ts deleted file mode 100644 index 35c7814..0000000 --- a/solution/frontend/src/features/builder/canvas/addable.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * 나중에 넣을 수 있는 섹션 — [+ 섹션 추가] 가 고르는 목록. - * - * 업종 시드(industryData)는 "처음부터 있는 것"이고 여기는 "필요하면 넣는 것"이다. - * 붙여넣기 아이템은 내용이 없으면 빈 섹션이라, 시드에 박아 두면 아무도 안 쓰는 칸이 늘 붙어 있다. - */ -import type {SectionItem} from '@o2o/shared'; -import {SECTION_DATA_SPEC} from './dataSpec'; -import {defaultVariant} from './registry'; -import type {ThumbKey} from './types'; - -export interface AddableSection { - type: string; - name: string; - description: string; - thumb: ThumbKey; -} - -/** - * 추가할 수 있는 섹션 목록. - * - * ★ dataSpec 이 단일 출처다 — 아이템을 하나 더 만들면 여기에도 자동으로 나타난다. - * 목록을 따로 들면 아이템을 추가하고 이 표를 잊어 "만들었는데 고를 수가 없는" 상태가 된다. - */ -export const ADDABLE_SECTIONS: AddableSection[] = Object.values(SECTION_DATA_SPEC).map((spec) => { - const variant = defaultVariant(spec.kind); - return { - type: spec.kind, - name: spec.label, - description: variant?.description ?? '', - thumb: variant?.thumb ?? 'cards', - }; -}); - -export function addableSection(type: string): AddableSection | undefined { - return ADDABLE_SECTIONS.find((item) => item.type === type); -} - -/** 목록에 새로 넣을 섹션 한 줄. id 는 타입과 같다 — 같은 아이템을 두 번 넣지 않는다. */ -export function newSectionOf(type: string): SectionItem | undefined { - const addable = addableSection(type); - if (!addable) return undefined; - return { - id: type, - type, - name: addable.name, - description: addable.description, - isLocked: false, - isEnabled: true, - }; -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/AddressCard.tsx b/solution/frontend/src/features/builder/canvas/primitives/AddressCard.tsx deleted file mode 100644 index 3ae94a1..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/AddressCard.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import {useState} from 'react'; -import {Check, Copy} from 'lucide-react'; -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; - -/** - * 주소 + 복사 + 지도 앱 링크. - * - * "오시는 길" 배리에이션들이 공통으로 쓰는 덩어리. 복사 성공 표시까지 여기 들어 있어서 - * 레이아웃을 바꿔도 사장님이 확인하는 동작은 똑같이 남는다. - */ -export function AddressCard({ - address, - searchName, - variant = 'card', - colors, - className, -}: { - address: string; - /** 네이버 지도에서 찾을 이름. 보통 상호. */ - searchName: string; - variant?: 'card' | 'bar'; - /** - * 템플릿 색 토큰. 주소 글자만 text 를 따른다. - * - * ★ 지도 버튼의 초록·노랑은 네이버/카카오의 색이라 토큰으로 바꾸지 않는다 — - * 손님이 "그 버튼"으로 알아보는 색이다. - */ - colors?: TemplateItem['colors']; - className?: string; -}) { - const [copied, setCopied] = useState(false); - const addressStyle = colors ? {color: colors.text} : undefined; - - const handleCopy = (e: React.MouseEvent) => { - e.stopPropagation(); - navigator.clipboard.writeText(address); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const links = ( - - ); - - if (variant === 'bar') { - return ( -
- - {address} - -
- - {links} -
-
- ); - } - - return ( -
-
- 도로명 주소 -
- - {address} - - -
-
- - {links} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/CtaLink.tsx b/solution/frontend/src/features/builder/canvas/primitives/CtaLink.tsx deleted file mode 100644 index bb5f455..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/CtaLink.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 캔버스 안의 행동 유도 링크. - * - * 캔버스는 클릭이 곧 "섹션 선택"이라, 링크는 반드시 stopPropagation 을 해야 한다. - * 그 한 줄을 배리에이션마다 손으로 쓰면 언젠가 빠진다 — 여기 한 곳에 가둔다. - */ -import type {ReactNode} from 'react'; -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; - -export type CtaStyle = 'solid' | 'outline' | 'pill' | 'ghost' | 'brand'; - -interface CtaLinkProps { - href: string; - children: ReactNode; - variant?: CtaStyle; - size?: 'sm' | 'md'; - /** 템플릿 색 토큰. 넘기면 버튼이 브랜드 색을 따른다. */ - colors?: TemplateItem['colors']; - block?: boolean; - className?: string; -} - -const BASE = - 'inline-flex items-center justify-center gap-1.5 font-semibold transition-all cursor-pointer'; - -const SIZE = { - sm: 'px-3 py-1.5 text-[11px]', - md: 'px-5 py-2.5 text-xs', -}; - -/** - * variant 마다 토큰이 닿는 자리가 다르다 — 채운 버튼은 배경, 흰 버튼은 글자색. - * - * ★ primary 는 이 서비스에서 "흰 글자를 얹는 어두운 색"이라는 계약이다. - * 그래서 배경으로 쓸 때는 primary 만 쓴다 — accent 를 배경에 깔면 흰 글자가 뜬다. - */ -function tokenStyle(variant: CtaStyle, colors?: TemplateItem['colors']) { - if (!colors) return undefined; - if (variant === 'brand' || variant === 'solid') return {backgroundColor: colors.primary}; - if (variant === 'pill' || variant === 'outline') return {color: colors.primary}; - return undefined; -} - -export function CtaLink({ - href, - children, - variant = 'solid', - size = 'md', - colors, - block, - className, -}: CtaLinkProps) { - return ( - e.stopPropagation()} - style={tokenStyle(variant, colors)} - className={cn( - BASE, - SIZE[size], - block && 'w-full', - variant === 'solid' && 'rounded-xl bg-stone-900 text-white shadow-xs hover:bg-stone-800', - variant === 'brand' && 'rounded-xl text-white shadow-xs hover:opacity-90', - variant === 'outline' && - 'rounded-xl border border-stone-300 bg-white text-stone-800 hover:bg-stone-100', - variant === 'pill' && - 'rounded-full bg-white text-stone-900 shadow-md hover:bg-stone-100', - variant === 'ghost' && 'rounded-lg text-stone-500 hover:bg-stone-100 hover:text-stone-900', - // 배경을 인라인 스타일로 덮으면 hover 배경 클래스가 안 먹는다 — 투명도로 대신 반응시킨다. - variant === 'solid' && colors && 'hover:opacity-90', - className, - )} - > - {children} - - ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/EmptyStateNotice.tsx b/solution/frontend/src/features/builder/canvas/primitives/EmptyStateNotice.tsx deleted file mode 100644 index 6bd8037..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/EmptyStateNotice.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type {ReactNode} from 'react'; -import {Clock3} from 'lucide-react'; - -/** - * "아직 준비 중" 한 줄. - * - * 실사업장은 객실·주변정보·영상 같은 목록이 아직 비어 있다(백엔드에 조회 창구가 - * 없는 것도 있다). 목록만 조용히 비우면 에디터에서는 섹션이 통째로 사라진 것처럼 보여 - * 사장님이 "내가 뭘 지웠나" 하고 되돌리려 든다 — 자리는 지키고 비었다는 사실만 말한다. - * - * 톤은 HiddenFieldsNotice 와 맞춘다: 사장님에게 하는 담백한 상태 보고지 손님용 카피가 아니다. - * (다만 저쪽은 "가리는 중"이라 경고색, 이쪽은 "아직 없음"이라 중립색이다.) - */ -export function EmptyStateNotice({children}: {children: ReactNode}) { - return ( -
- - {children} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/FeatureCard.tsx b/solution/frontend/src/features/builder/canvas/primitives/FeatureCard.tsx deleted file mode 100644 index 0e94a15..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/FeatureCard.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type {LucideIcon} from 'lucide-react'; -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; - -/** - * 아이콘 + 제목 + 한 줄 설명. 주차/충전기/편의시설처럼 "있다/없다"를 알리는 칸. - */ -export function FeatureCard({ - icon: Icon, - title, - description, - iconClassName, - colors, - className, -}: { - icon: LucideIcon; - title: string; - description?: string; - iconClassName?: string; - /** 템플릿 색 토큰. 제목과 아이콘이 브랜드 색을 따른다. */ - colors?: TemplateItem['colors']; - className?: string; -}) { - // ★ iconClassName 은 "전기차 충전기는 초록" 처럼 뜻이 정해진 색을 넣는 자리다. - // 그 위에 토큰을 덮으면 의미가 사라지므로, 지정이 없을 때만 primary 를 쓴다. - const iconStyle = colors && !iconClassName ? {color: colors.primary} : undefined; - - return ( -
- -
- - {title} - - {description &&

{description}

} -
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/HiddenFieldsNotice.tsx b/solution/frontend/src/features/builder/canvas/primitives/HiddenFieldsNotice.tsx deleted file mode 100644 index 239a9fe..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/HiddenFieldsNotice.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import {AlertCircle} from 'lucide-react'; - -/** - * "미확인 항목 N개 숨김 중" 배지. - * - * 미검증 값은 캔버스에서 아예 빼되, 빠졌다는 사실은 반드시 보여준다 — - * 조용히 사라지면 사장님은 "이미 나가고 있다"고 오해한다. - */ -export function HiddenFieldsNotice({count}: {count: number}) { - if (count <= 0) return null; - - return ( - - - 미확인 항목 {count}개 숨김 중 - - ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/InfoRow.tsx b/solution/frontend/src/features/builder/canvas/primitives/InfoRow.tsx deleted file mode 100644 index 2bedce4..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/InfoRow.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 기본 정보 한 줄 — 라벨/값 한 쌍을 어떤 모양으로 낼지가 통째로 배리에이션이다. - * (표의 한 행 · 카드 한 장 · 문장 속 인라인) - */ -import {cn} from '@/lib/utils'; -import type {InfoField, TemplateItem} from '@o2o/shared'; - -export type InfoRowStyle = 'row' | 'card' | 'inline'; - -export function InfoRow({ - field, - variant = 'row', - colors, - className, -}: { - field: InfoField; - variant?: InfoRowStyle; - /** 템플릿 색 토큰. 값(굵은 글씨)만 text 를 따르고 라벨은 중립 회색으로 남는다. */ - colors?: TemplateItem['colors']; - className?: string; -}) { - const valueStyle = colors ? {color: colors.text} : undefined; - - if (variant === 'card') { - return ( -
- - {field.label} - - - {field.value} - -
- ); - } - - if (variant === 'inline') { - return ( - - {field.label} - - {field.value} - - - ); - } - - return ( -
- {field.label} - - {field.value} - -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/Lightbox.tsx b/solution/frontend/src/features/builder/canvas/primitives/Lightbox.tsx deleted file mode 100644 index 7950176..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/Lightbox.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import {X, ChevronLeft, ChevronRight} from 'lucide-react'; -import type {TilePhoto} from './PhotoTile'; - -/** - * 사진 확대 보기. 갤러리 배리에이션 세 개가 전부 이걸 그대로 쓴다 — - * 확대 동작까지 배리에이션마다 다르면 사장님이 레이아웃을 바꿀 때마다 조작법을 다시 배운다. - */ -export function Lightbox({ - photos, - index, - onClose, - onMove, -}: { - photos: TilePhoto[]; - index: number | null; - onClose: () => void; - onMove?: (next: number) => void; -}) { - if (index === null || !photos[index]) return null; - - const photo = photos[index]; - const step = (delta: number) => onMove?.((index + delta + photos.length) % photos.length); - - return ( -
{ - e.stopPropagation(); - onClose(); - }} - className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 backdrop-blur-md" - > - - - {onMove && photos.length > 1 && ( - <> - - - - )} - -
e.stopPropagation()} - className="flex max-h-[85vh] max-w-3xl items-center" - > - {photo.alt -
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/PhotoTile.tsx b/solution/frontend/src/features/builder/canvas/primitives/PhotoTile.tsx deleted file mode 100644 index e225621..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/PhotoTile.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import {cn} from '@/lib/utils'; - -export interface TilePhoto { - id: string; - url: string; - alt?: string; - caption?: string; - category?: string; -} - -type TileFit = 'square' | 'portrait' | 'landscape' | 'free'; - -const ASPECT: Record = { - square: 'aspect-square', - portrait: 'aspect-3/4', - landscape: 'aspect-4/3', - free: '', -}; - -/** - * 갤러리 한 칸. 격자든 벽돌쌓기든 슬라이드든 "칸 하나"의 모양은 같아야 한다 — - * 배리에이션이 바꾸는 건 칸의 배치이지 칸 자체가 아니다. - */ -export function PhotoTile({ - photo, - fit = 'square', - onOpen, - showMeta = false, - className, -}: { - photo: TilePhoto; - fit?: TileFit; - onOpen?: () => void; - showMeta?: boolean; - className?: string; -}) { - return ( -
{ - e.stopPropagation(); - onOpen?.(); - }} - className={cn( - 'group relative select-none overflow-hidden rounded-xl border border-stone-200/80 bg-stone-100', - ASPECT[fit], - onOpen && 'cursor-pointer', - className, - )} - > - {photo.alt - {showMeta && (photo.category || photo.caption) && ( -
- {photo.category ? ( - - {photo.category} - - ) : ( - - )} - {photo.caption && ( - {photo.caption} - )} -
- )} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/Pill.tsx b/solution/frontend/src/features/builder/canvas/primitives/Pill.tsx deleted file mode 100644 index 1b0f9c2..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/Pill.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type {ReactNode} from 'react'; -import {cn} from '@/lib/utils'; - -type PillTone = 'neutral' | 'accent' | 'warn' | 'good' | 'dark'; - -const TONE: Record = { - neutral: 'bg-stone-100 text-stone-600 border-stone-200/70', - accent: 'bg-amber-100/80 text-amber-900 border-amber-200/70', - warn: 'bg-amber-50 text-amber-700 border-amber-200', - good: 'bg-emerald-50 text-emerald-700 border-emerald-200/60', - dark: 'bg-stone-900/85 text-white border-transparent', -}; - -/** 작은 라벨 하나. 거리·태그·가격대·"대표축제" 처럼 값 옆에 붙는 부가 정보 전용. */ -export function Pill({ - children, - tone = 'neutral', - mono, - className, -}: { - children: ReactNode; - tone?: PillTone; - mono?: boolean; - className?: string; -}) { - return ( - - {children} - - ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx b/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx deleted file mode 100644 index 1d94492..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react'; -import {ArrowUpRight, ChevronLeft, ChevronRight} from 'lucide-react'; -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; -import type {GuideCard} from '../variants/local/types'; -import {walkMinutes} from '../variants/local/walking'; - -const naverSearch = (q: string) => - `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; - -/** 네이버지도 검색 — 맛집·명소 카드가 쓴다(실제 사이트 LocalGuideSection과 동일한 규칙). */ -export const naverMapSearch = (q: string) => `https://map.naver.com/p/search/${encodeURIComponent(q)}`; - -/** - * 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기". - * - * ★ 사진이 없으면 회색 판에 이름을 크게 쓴다(스크린샷의 '군산복집' 카드). 자리를 비우거나 - * 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다. - * ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다. - */ -function GuideCardView({ - card, - colors, - leading, - linkUrl = naverSearch, -}: { - card: GuideCard; - colors: TemplateItem['colors']; - leading?: ReactNode; - linkUrl?: (query: string) => string; -}) { - const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined; - return ( - e.stopPropagation()} - className="group flex w-[240px] shrink-0 snap-start flex-col overflow-hidden rounded-lg border border-stone-200/80 bg-white shadow-2xs transition-shadow hover:shadow-md sm:w-[260px]" - > -
- {card.imageUrl ? ( - {card.name} - ) : ( -
- {card.name} -
- )} - {minutes !== undefined && ( - - 도보 약 {minutes}분 - {card.distanceText && {card.distanceText}} - - )} -
- -
- {leading} -

- {card.name} -

- {card.description && ( -

{card.description}

- )} - - 검색으로 열기 - - -
-
- ); -} - -/** - * 카드 캐러셀 — 가로 스크롤(스냅) + 좌우 화살표 + 점 페이지네이션 + "1 / N". - * - * ★ 페이지 = 한 화면에 온전히 들어가는 카드 수. 미리보기 해상도(PC/태블릿/모바일)가 바뀌면 - * ResizeObserver 가 다시 센다 — 고정 4장으로 두면 모바일에서 점이 카드 수와 어긋난다. - * ★ 화살표는 컨테이너 폭만큼 넘긴다(한 페이지). 한 장씩 넘기면 24장에 화살표 23번이다. - */ -export function PlaceCarousel({ - cards, - colors, - renderLeading, - linkUrl, -}: { - cards: GuideCard[]; - colors: TemplateItem['colors']; - /** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */ - renderLeading?: (card: GuideCard) => ReactNode; - /** 카드가 열 링크. 기본은 네이버 웹 검색이고, 맛집·명소는 네이버지도 검색(naverMapSearch)을 넘긴다. */ - linkUrl?: (query: string) => string; -}) { - const trackRef = useRef(null); - const [perPage, setPerPage] = useState(1); - const [page, setPage] = useState(0); - - const pages = Math.max(1, Math.ceil(cards.length / perPage)); - - // 카드 한 장 폭(gap 포함)으로 한 화면에 몇 장 들어가는지 잰다. - const measure = useCallback(() => { - const track = trackRef.current; - const first = track?.firstElementChild as HTMLElement | null; - if (!track || !first) return; - const gap = parseFloat(getComputedStyle(track).columnGap || '0') || 0; - const step = first.offsetWidth + gap; - const fit = Math.max(1, Math.floor((track.clientWidth + gap) / step)); - setPerPage(fit); - setPage(Math.min(Math.round(track.scrollLeft / (step * fit)), Math.max(0, Math.ceil(cards.length / fit) - 1))); - }, [cards.length]); - - useEffect(() => { - measure(); - const track = trackRef.current; - if (!track) return; - const ro = new ResizeObserver(measure); - ro.observe(track); - return () => ro.disconnect(); - }, [measure]); - - // 필터가 바뀌어 카드가 줄면 첫 페이지로. - useEffect(() => { - trackRef.current?.scrollTo({left: 0}); - setPage(0); - }, [cards]); - - const scrollToPage = (next: number) => { - const track = trackRef.current; - if (!track) return; - const clamped = Math.max(0, Math.min(pages - 1, next)); - track.scrollTo({left: clamped * track.clientWidth, behavior: 'smooth'}); - setPage(clamped); - }; - - const onScroll = () => { - const track = trackRef.current; - if (!track || track.clientWidth === 0) return; - setPage(Math.max(0, Math.min(pages - 1, Math.round(track.scrollLeft / track.clientWidth)))); - }; - - const arrow = - 'flex size-9 cursor-pointer items-center justify-center rounded-full border transition-colors disabled:cursor-default disabled:opacity-30'; - - return ( -
-
- - -
- -
- {cards.map((card) => ( - - ))} -
- - {pages > 1 && ( -
-
- {Array.from({length: pages}, (_, i) => ( -
- - {page + 1} / {pages} - -
- )} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/PlaceRow.tsx b/solution/frontend/src/features/builder/canvas/primitives/PlaceRow.tsx deleted file mode 100644 index 098d775..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/PlaceRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import {ArrowUpRight} from 'lucide-react'; -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; -import {Pill} from './Pill'; - -/** - * "이름 · 거리 · 한 줄 설명 · 바로가기" 한 행. - * 맛집 / 명소 / 축제가 전부 이 모양이라 하나로 묶는다. - */ -export function PlaceRow({ - name, - meta, - description, - href, - actionLabel = '길찾기', - leading, - colors, - className, -}: { - name: string; - meta?: string; - description?: string; - href?: string; - actionLabel?: string; - leading?: React.ReactNode; - /** 템플릿 색 토큰. 상호명만 text 를 따른다 — 설명·바로가기는 중립 회색이어야 이름이 튄다. */ - colors?: TemplateItem['colors']; - className?: string; -}) { - const body = ( - <> -
-
- {leading} - - {name} - - {meta && {meta}} -
- {description && ( -

{description}

- )} -
- - {href && ( - - {actionLabel} - - - )} - - ); - - const shared = cn( - 'group flex items-center justify-between p-4 transition-colors hover:bg-stone-50/80', - className, - ); - - if (!href) return
{body}
; - - return ( - e.stopPropagation()} - className={shared} - > - {body} - - ); -} - -/** 목록을 감싸는 카드. divide 로 행 사이 선을 긋는다. */ -export function ListCard({children, className}: {children: React.ReactNode; className?: string}) { - return ( -
*+*]:border-t [&>*+*]:border-stone-100', - className, - )} - > - {children} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/PriceRow.tsx b/solution/frontend/src/features/builder/canvas/primitives/PriceRow.tsx deleted file mode 100644 index ac8ad7a..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/PriceRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; -import {Pill} from './Pill'; - -export interface PricedItem { - name: string; - price?: string; - desc?: string; - tag?: string; - /** 소요시간 · 대상 같은 부가 스펙. 프로그램/코스에서 쓴다. */ - meta?: string[]; -} - -/** - * "이름 · 태그 · 설명 · 가격" 한 줄. - * 메뉴 / 코스 / 체험 프로그램이 전부 같은 뼈대라 한 원자로 묶고, 배치만 배리에이션에 맡긴다. - */ -export function PriceRow({ - item, - variant = 'row', - colors, - className, -}: { - item: PricedItem; - variant?: 'row' | 'card'; - /** 템플릿 색 토큰. 품명은 text, 가격은 primary 를 따른다(가격이 이 원자의 주인공이다). */ - colors?: TemplateItem['colors']; - className?: string; -}) { - const nameStyle = colors ? {color: colors.text} : undefined; - const priceStyle = colors ? {color: colors.primary} : undefined; - - const head = ( -
- - {item.name} - - {item.tag && {item.tag}} -
- ); - - const specs = item.meta?.length ? ( -
- {item.meta.map((m) => ( - {m} - ))} -
- ) : null; - - if (variant === 'card') { - return ( -
-
- {head} - {item.desc &&

{item.desc}

} - {specs} -
- {item.price && ( - - {item.price} - - )} -
- ); - } - - return ( -
-
- {head} - {item.desc &&

{item.desc}

} - {specs} -
- {item.price && ( - - {item.price} - - )} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/Rail.tsx b/solution/frontend/src/features/builder/canvas/primitives/Rail.tsx deleted file mode 100644 index 8607fe0..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/Rail.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * 캔버스의 가로 슬라이더. - * - * ★ 왜 라이브러리인가 (2026-09-02) - * 예전에는 `overflow-x: auto` 상자 + 화살표 버튼 두 개였다. 화살표는 `scrollBy` 를 불러 - * 한 화면의 80%씩 밀었는데, **카드를 잡아끄는 방법이 없었다** — 휠 마우스 사용자는 - * 화살표를 찾지 못하면 옆에 더 있다는 걸 몰랐고, 트랙패드에서만 자연스러웠다. - * embla-carousel 은 의존성 0, 코어 ~5KB(gzip)이고 발행본(solution/site)이 쓰는 것과 같다 — - * 미리보기와 발행본의 조작감이 갈리면 미리보기가 아니다. - * - * ★ 캔버스 전용 사정: 카드 안에 링크·버튼이 있다. 드래그를 시작한 뒤의 클릭은 삼킨다 - * (embla 의 `pointerUp` 뒤 click 은 막지 않으면 카드가 눌린 것으로 처리된다). - */ -import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react'; -import useEmblaCarousel from 'embla-carousel-react'; -import {ChevronLeft, ChevronRight} from 'lucide-react'; -import {ITEM_BORDER, ITEM_CARD} from '../variants/items/common'; - -export function Rail({ - label, - children, - tone = 'light', - gap = 1, - className, - nav = 'above', - viewportClassName, - trackClassName = 'items-start', -}: { - /** 스크린리더가 읽을 이름. 화살표의 aria-label 도 여기서 만든다. */ - label: string; - children: ReactNode; - tone?: 'light' | 'dark'; - /** 슬라이드 사이 간격(rem). */ - gap?: number; - className?: string; - /** - * 화살표를 어디에 둘지. 슬라이더가 액자(필름·책장) 안에 들어가는 아이템은 - * 'below' 로 액자 밖에 세운다 — 액자 안에 넣으면 질감 위에 버튼이 떠서 물건처럼 안 보인다. - */ - nav?: 'above' | 'below' | 'overlay' | 'none'; - /** 창(viewport)에 붙일 클래스. 액자 안쪽 여백처럼 슬라이더 자체가 가져야 하는 값. */ - viewportClassName?: string; - /** 트랙에 붙일 클래스. 세로 정렬(items-end 같은)이 아이템마다 다르다. */ - trackClassName?: string; -}) { - const [emblaRef, embla] = useEmblaCarousel({align: 'start', containScroll: 'trimSnaps'}); - const [snaps, setSnaps] = useState([]); - const [selected, setSelected] = useState(0); - const [canPrev, setCanPrev] = useState(false); - const [canNext, setCanNext] = useState(false); - const viewport = useRef(null); - const dragged = useRef(false); - - useEffect(() => { - if (!embla) return; - const sync = () => { - setSelected(embla.selectedScrollSnap()); - setCanPrev(embla.canScrollPrev()); - setCanNext(embla.canScrollNext()); - }; - const measure = () => { - setSnaps(embla.scrollSnapList()); - sync(); - }; - measure(); - embla.on('select', sync); - embla.on('reInit', measure); - viewport.current?.setAttribute('data-slider', 'on'); - return () => { - embla.off('select', sync); - embla.off('reInit', measure); - }; - }, [embla]); - - // 끌고 나서 손을 떼면 그 자리의 카드가 클릭된 것으로 처리된다 — 한 번만 삼킨다. - useEffect(() => { - if (!embla) return; - const down = () => { - dragged.current = false; - viewport.current?.setAttribute('data-dragging', 'true'); - }; - const move = () => { - dragged.current = true; - }; - const up = () => viewport.current?.removeAttribute('data-dragging'); - embla.on('pointerDown', down); - embla.on('scroll', move); - embla.on('pointerUp', up); - return () => { - embla.off('pointerDown', down); - embla.off('scroll', move); - embla.off('pointerUp', up); - }; - }, [embla]); - - const prev = useCallback(() => embla?.scrollPrev(), [embla]); - const next = useCallback(() => embla?.scrollNext(), [embla]); - - // 한 화면에 다 들어가면 조작부를 그리지 않는다 — 눌러도 안 움직이는 버튼은 고장으로 읽힌다. - const movable = snaps.length > 1; - - // 액자(필름·책장) 안에 든 슬라이더는 화살표를 창 위에 겹친다 — 액자 밖에 두면 - // 어느 줄을 미는 버튼인지 알 수 없고, 액자 안에 한 줄 더 두면 질감이 끊긴다. - const overlay = movable && nav === 'overlay' && ( - <> - - - - ); - - const controls = movable && (nav === 'above' || nav === 'below') && ( -
- - {selected + 1} / {snaps.length} - - - -
- ); - - return ( -
- {nav === 'above' && controls} - -
{ - viewport.current = node; - emblaRef(node); - }} - className={`w4-scroll ${viewportClassName ?? ''}`} - role="group" - aria-roledescription="캐러셀" - aria-label={label} - tabIndex={0} - onKeyDown={(event) => { - if (event.key === 'ArrowLeft') { - event.preventDefault(); - prev(); - } - if (event.key === 'ArrowRight') { - event.preventDefault(); - next(); - } - }} - onClickCapture={(event) => { - if (!dragged.current) return; - event.preventDefault(); - event.stopPropagation(); - dragged.current = false; - }} - > -
- {children} -
-
- - {nav === 'below' && controls} - {overlay} -
- ); -} - -function EdgeButton({ - dir, - onClick, - disabled, - tone, - label, -}: { - dir: 'prev' | 'next'; - onClick: () => void; - disabled: boolean; - tone: 'light' | 'dark'; - label: string; -}) { - const Icon = dir === 'prev' ? ChevronLeft : ChevronRight; - const dark = tone === 'dark'; - return ( - - ); -} - -function NavButton({ - dir, - onClick, - disabled, - tone, - label, -}: { - dir: 'prev' | 'next'; - onClick: () => void; - disabled: boolean; - tone: 'light' | 'dark'; - label: string; -}) { - const Icon = dir === 'prev' ? ChevronLeft : ChevronRight; - const dark = tone === 'dark'; - return ( - - ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/SectionFrame.tsx b/solution/frontend/src/features/builder/canvas/primitives/SectionFrame.tsx deleted file mode 100644 index ddce39d..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/SectionFrame.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 섹션 껍데기 — 배리에이션이 절대 다시 만들지 않는 부분. - * - * 앵커 id(모바일 탭이 스크롤하는 목표), 선택 링, "선택됨" 배지, 배경 톤이 여기 모여 있다. - * 배리에이션은 안쪽 내용만 책임진다 — 껍데기가 제각각이면 레이아웃을 바꿀 때마다 - * 선택 표시가 사라지거나 앵커가 끊긴다. - */ -import type {ReactNode} from 'react'; -import type {SectionItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; -import type {SectionTone} from '../types'; - -/** - * 섹션 배경 톤 → 실제 색. - * - * 화면 면적의 대부분이 이 네 색이라, 여기가 하드코딩이면 팔레트를 바꿔도 인상이 안 바뀐다. - * 그래서 템플릿 토큰(`--tpl-*`)을 읽고, 폴백으로 기존 색을 그대로 둔다 — - * ★ 토큰을 안 내려보내는 화면(발행 사이트·썸네일 등)에서는 지금과 픽셀 단위로 동일하다. - */ -const TONE_BG: Record = { - paper: 'var(--tpl-surface, #faf7f2)', - // ★ 진짜 흰색으로 남긴다 — surface/surfaceAlt 와 세 단계가 있어야 섹션이 서로 구분된다. - white: 'var(--tpl-bg, #ffffff)', - tint: 'var(--tpl-surface-alt, #f4f1ec)', - dark: 'var(--tpl-inverse, #1c1917)', -}; - -interface SectionFrameProps { - section: SectionItem; - isSelected: boolean; - onSelect: () => void; - /** 배경 톤. 위아래 섹션과 다른 톤을 주면 경계선 없이도 구분된다. */ - tone?: SectionTone; - /** 히어로처럼 자기 여백을 직접 잡는 배리에이션은 false. */ - padded?: boolean; - className?: string; - children: ReactNode; -} - -export function SectionFrame({ - section, - isSelected, - onSelect, - tone = 'white', - padded = true, - className, - children, -}: SectionFrameProps) { - const isDark = tone === 'dark'; - - return ( -
- {isSelected && ( - - 선택됨: {section.name} - {section.isLocked && ' (필수)'} - - )} - - {children} -
- ); -} - -/** 섹션 본문의 표준 폭. 배리에이션마다 폭이 다르면 스크롤할 때 글줄이 출렁인다. */ -export function SectionBody({ - children, - width = 'default', - className, -}: { - children: ReactNode; - width?: 'default' | 'wide' | 'narrow'; - className?: string; -}) { - return ( -
- {children} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/SectionHeading.tsx b/solution/frontend/src/features/builder/canvas/primitives/SectionHeading.tsx deleted file mode 100644 index bf54af1..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/SectionHeading.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * 섹션 제목 — 그 자체로 배리에이션을 갖는 원자. - * - * 섹션 배리에이션을 바꿔도 제목 스타일은 따로 고를 수 있어야 한다 - * ("갤러리는 격자로, 제목은 가운데 정렬" 같은 조합이 실제로 나온다). - */ -import type {ReactNode} from 'react'; -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; - -export type HeadingStyle = 'underline' | 'centered' | 'eyebrow' | 'minimal'; - -interface SectionHeadingProps { - title: string; - subtitle?: string; - /** 제목 위에 얹는 작은 라벨. eyebrow · centered 스타일에서 쓴다. */ - eyebrow?: string; - variant?: HeadingStyle; - /** 제목 오른쪽에 붙는 것(경고 배지, 추가 버튼 등). */ - trailing?: ReactNode; - /** - * 템플릿 색 토큰. 넘기면 제목은 text, eyebrow 는 accent 를 따른다. - * - * ★ 부제·밑줄은 일부러 중립 회색으로 남긴다 — 여기까지 토큰을 먹이면 - * accent 가 밝은 템플릿에서 부제가 배경에 묻힌다. - */ - colors?: TemplateItem['colors']; - className?: string; -} - -export function SectionHeading({ - title, - subtitle, - eyebrow, - variant = 'underline', - trailing, - colors, - className, -}: SectionHeadingProps) { - const titleStyle = colors ? {color: colors.text} : undefined; - const eyebrowStyle = colors ? {color: colors.accent} : undefined; - - if (variant === 'centered') { - return ( -
- {eyebrow && ( - - {eyebrow} - - )} -

- {title} -

- {subtitle &&

{subtitle}

} - {trailing &&
{trailing}
} -
- ); - } - - if (variant === 'eyebrow') { - return ( -
-
- {eyebrow && ( - - {eyebrow} - - )} -

- {title} -

- {subtitle &&

{subtitle}

} -
- {trailing} -
- ); - } - - if (variant === 'minimal') { - // 여기서 제목은 사실상 작은 라벨이라 text 가 아니라 secondary 를 쓴다. - return ( -
-

- {title} -

- {trailing} -
- ); - } - - // underline — 기본. 아래 실선이 섹션의 시작을 확실히 끊어준다. - return ( -
-
-

- {title} -

- {subtitle &&

{subtitle}

} -
- {trailing &&
{trailing}
} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx b/solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx deleted file mode 100644 index 70da7ae..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/WalkFilterTabs.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type {TemplateItem} from '@o2o/shared'; -import {cn} from '@/lib/utils'; -import { - WALK_FILTERS, - matchesWalkFilter, - type WalkFilterKey, -} from '../variants/local/walking'; - -/** - * 도보 시간 필터 탭 — "전체 24 · 걸어서 5분 이내 8 · 10분 이내 19 · 10분 이상 5". - * - * ★ 구간은 누적이다(walking.ts 주석). 그래서 숫자가 서로 더해져 전체가 되지 않는다 — - * "5분 이내" ⊂ "10분 이내". 배타 구간으로 바꾸면 라벨("이내")과 숫자가 어긋난다. - * ★ 상태는 부르는 쪽(카테고리 섹션)이 든다. 섹션마다 필터가 따로 움직여야 한다. - */ -export function WalkFilterTabs({ - distances, - value, - onChange, - colors, -}: { - /** 항목별 거리(m). 모르는 항목은 undefined — '전체'에만 센다. */ - distances: (number | undefined)[]; - value: WalkFilterKey; - onChange: (key: WalkFilterKey) => void; - colors: TemplateItem['colors']; -}) { - return ( -
- {WALK_FILTERS.map(({key, label}) => { - const count = distances.filter((m) => matchesWalkFilter(key, m)).length; - const active = key === value; - return ( - - ); - })} -
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/primitives/index.ts b/solution/frontend/src/features/builder/canvas/primitives/index.ts deleted file mode 100644 index c444d8b..0000000 --- a/solution/frontend/src/features/builder/canvas/primitives/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * 캔버스 원자 모음. - * - * 배리에이션은 여기 있는 것만 조립한다 — 원자를 늘리면 모든 배리에이션이 같이 좋아지고, - * 배리에이션 안에서 직접 마크업을 쓰기 시작하면 레이아웃마다 디테일이 갈라진다. - */ -export {SectionFrame, SectionBody} from './SectionFrame'; -export {SectionHeading, type HeadingStyle} from './SectionHeading'; -export {InfoRow, type InfoRowStyle} from './InfoRow'; -export {HiddenFieldsNotice} from './HiddenFieldsNotice'; -export {EmptyStateNotice} from './EmptyStateNotice'; -export {PhotoTile, type TilePhoto} from './PhotoTile'; -export {Lightbox} from './Lightbox'; -export {PlaceRow, ListCard} from './PlaceRow'; -export {PriceRow, type PricedItem} from './PriceRow'; -export {AddressCard} from './AddressCard'; -export {FeatureCard} from './FeatureCard'; -export {Pill} from './Pill'; -export {CtaLink, type CtaStyle} from './CtaLink'; -export {Rail} from './Rail'; -/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */ -export {PlaceCarousel, naverMapSearch} from './PlaceCarousel'; -export {WalkFilterTabs} from './WalkFilterTabs'; diff --git a/solution/frontend/src/features/builder/canvas/registry.ts b/solution/frontend/src/features/builder/canvas/registry.ts deleted file mode 100644 index 420283a..0000000 --- a/solution/frontend/src/features/builder/canvas/registry.ts +++ /dev/null @@ -1,519 +0,0 @@ -/** - * 배리에이션 레지스트리 — "이 섹션 타입에 어떤 레이아웃들이 있나"의 단일 출처. - * - * 캔버스 렌더러와 [디자인] 탭이 같은 표를 본다. 배리에이션을 하나 추가한다는 건 - * 여기 한 줄을 더한다는 뜻이고, 그 순간 캔버스와 고르는 UI가 동시에 늘어난다. - */ -import type {IndustryType, SectionItem} from '@o2o/shared'; -import {SocialCanvasSection} from '@/features/social/SocialCanvasSection'; -import type {SectionVariant} from './types'; - -import {HeroCover} from './variants/hero/HeroCover'; -import {HeroSplit} from './variants/hero/HeroSplit'; - -import {IntroStory} from './variants/intro/IntroStory'; -import {IntroCentered} from './variants/intro/IntroCentered'; -import {IntroSideBySide} from './variants/intro/IntroSideBySide'; - -import {InfoTable} from './variants/info/InfoTable'; -import {InfoCards} from './variants/info/InfoCards'; -import {InfoInline} from './variants/info/InfoInline'; - -import {PhotosGrid} from './variants/photos/PhotosGrid'; -import {PhotosMasonry} from './variants/photos/PhotosMasonry'; -import {PhotosCarousel} from './variants/photos/PhotosCarousel'; -import {PhotosWithVideos} from './variants/photos/PhotosWithVideos'; - -import {MapDetailed} from './variants/map/MapDetailed'; -import {MapCompact} from './variants/map/MapCompact'; - -import {ItineraryTickets} from './variants/itinerary/ItineraryTickets'; -import {VideoFrame} from './variants/video/VideoFrame'; -import {LocalGuide} from './variants/local/LocalGuide'; -import {WeatherSection} from './variants/weather/WeatherSection'; - -import {FaqAccordion} from './variants/faq/FaqAccordion'; -import {FaqOpenList} from './variants/faq/FaqOpenList'; -import {FaqTwoColumn} from './variants/faq/FaqTwoColumn'; - -import {RoomsCarousel} from './variants/rooms/RoomsCarousel'; -import {RoomsGrid} from './variants/rooms/RoomsGrid'; -import {RoomsList} from './variants/rooms/RoomsList'; - - -import {BookingCard} from './variants/booking/BookingCard'; -import {BookingBanner} from './variants/booking/BookingBanner'; - -import {MenuList} from './variants/menu/MenuList'; -import {MenuGrid} from './variants/menu/MenuGrid'; -import {MenuPriceTable} from './variants/menu/MenuPriceTable'; - -import {SpaceZones} from './variants/space/SpaceZones'; -import {SpaceList} from './variants/space/SpaceList'; - -import {InquiryCta} from './variants/inquiry/InquiryCta'; -import {InquiryForm} from './variants/inquiry/InquiryForm'; - -import {ProgramsCards} from './variants/programs/ProgramsCards'; -import {ProgramsTable} from './variants/programs/ProgramsTable'; - -import {ExhibitionGallery} from './variants/exhibition/ExhibitionGallery'; -import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice'; - -// 붙여넣기 아이템 — 데이터가 fact 가 아니라 사장님이 넣은 JSON 에서 온다(dataSpec.ts). -import {SongsTurntable} from './variants/songs/SongsTurntable'; -import {DailyCalendar} from './variants/daily/DailyCalendar'; -import {PeopleFilmstrip} from './variants/people/PeopleFilmstrip'; -import {ChronicleRail} from './variants/chronicle/ChronicleRail'; -import {ReadingRail} from './variants/reading/ReadingRail'; -import {PostcardStack} from './variants/postcard/PostcardStack'; -import {QuizFlip} from './variants/quiz/QuizFlip'; - -export const SECTION_VARIANTS: Record = { - /* - * 히어로. - * - * ★ 발행본이 실제로 해석하는 것과 **1:1 이어야 한다**(`site/src/sections/HeroSection.tsx`). - * 예전에는 에디토리얼·풀스크린·타이포 셋을 더 보여줬는데 발행본에 짝이 없어서, - * 무엇을 골라도 발행본은 기본(표지)으로 나왔다 — 고르는 의미가 없는 선택지였다. - * 여기 늘릴 때는 발행본 쪽 분기도 같이 늘린다. - */ - hero: [ - { - id: 'hero.cover', - name: '표지', - description: '사진 한 장 위, 상호는 좌하단. 사진의 피사체를 글자가 가리지 않는다.', - thumb: 'fullbleed', - Component: HeroCover, - isDefault: true, - }, - { - id: 'hero.split', - name: '좌우 분할', - description: '왼쪽 글 · 오른쪽 사진. 무엇을 파는 곳인지 글로 먼저 읽힌다.', - thumb: 'split', - Component: HeroSplit, - }, - ], - - intro: [ - { - id: 'intro.story', - name: '스토리', - description: '넓은 사진 아래로 문단이 흐르는 잡지식 구성.', - thumb: 'stack', - Component: IntroStory, - isDefault: true, - }, - { - id: 'intro.centered', - name: '가운데 정렬', - description: '짧은 문단 하나. 가장 조용하고 짧다.', - thumb: 'centered', - Component: IntroCentered, - }, - { - id: 'intro.side-by-side', - name: '사진 옆 글', - description: '세로 사진 옆에 문단과 특징 목록.', - thumb: 'split', - Component: IntroSideBySide, - }, - ], - - info: [ - { - id: 'info.table', - name: '표', - description: '라벨과 값을 선으로 나눈다. 항목이 많아도 안 흔들린다.', - thumb: 'table', - Component: InfoTable, - isDefault: true, - }, - { - id: 'info.cards', - name: '카드', - description: '항목마다 한 장. 모바일에서 훑기 좋다.', - thumb: 'cards', - Component: InfoCards, - }, - { - id: 'info.inline', - name: '한 줄 요약', - description: '값들을 이어 붙여 가장 좁게. 정보가 적을 때.', - thumb: 'compact', - Component: InfoInline, - }, - ], - - photos: [ - { - id: 'photos.grid', - name: '정사각 격자', - description: '같은 크기로 정렬. 장수가 많을수록 정돈돼 보인다.', - thumb: 'grid', - Component: PhotosGrid, - isDefault: true, - }, - { - id: 'photos.masonry', - name: '벽돌쌓기', - description: '원본 비율 그대로 어긋나게. 사진을 잘라내지 않는다.', - thumb: 'masonry', - Component: PhotosMasonry, - }, - { - id: 'photos.carousel', - name: '가로 슬라이드', - description: '큰 사진을 한 장씩. 장수가 적고 한 장이 좋을 때.', - thumb: 'carousel', - Component: PhotosCarousel, - }, - { - id: 'photos.with-videos', - name: '사진 + 영상', - description: '격자 갤러리 아래 YouTube 영상까지. 영상이 더 잘 팔릴 때.', - thumb: 'stack', - Component: PhotosWithVideos, - }, - ], - - map: [ - { - id: 'map.detailed', - name: '상세 안내', - description: '주소 + 거점별 소요시간 표 + 주차/충전. 차로 오는 손님이 많을 때.', - thumb: 'table', - Component: MapDetailed, - isDefault: true, - }, - { - id: 'map.compact', - name: '주소만', - description: '주소 한 줄과 길찾기 버튼. 도심처럼 찾기 쉬운 곳.', - thumb: 'compact', - Component: MapCompact, - }, - ], - - // ★ 전체/탭/요약 세 개를 하나로 통일했다(2026-09-07). 저장된 옛 id(local.tabs·local.compact)는 - // resolveVariant 가 기본값으로 떨어뜨리므로 기존 사이트가 깨지지 않는다. - local: [ - { - id: 'local.guide', - name: '가이드', - description: '맛집 · 명소 · 축제를 도보 시간으로 걸러 카드로 넘겨 본다.', - thumb: 'stack', - Component: LocalGuide, - isDefault: true, - }, - ], - - weather: [ - { - id: 'weather.card', - name: '날씨 카드', - description: '현재 기온과 날씨 상태, 추천 문구를 한 장에 보여준다.', - thumb: 'compact', - Component: WeatherSection, - isDefault: true, - }, - ], - - faq: [ - { - id: 'faq.accordion', - name: '아코디언', - description: '질문만 보이고 누르면 답이 열린다. 화면이 짧다.', - thumb: 'accordion', - Component: FaqAccordion, - isDefault: true, - }, - { - id: 'faq.open-list', - name: '펼친 목록', - description: '답까지 전부 열어 둔다. 훑고 지나가게 하고 싶을 때.', - thumb: 'list', - Component: FaqOpenList, - }, - { - id: 'faq.two-column', - name: '2단', - description: '넓은 화면에서 두 줄씩. 세로 길이가 반으로 준다.', - thumb: 'two-column', - Component: FaqTwoColumn, - }, - ], - - rooms: [ - { - id: 'rooms.carousel', - name: '가로 슬라이드', - description: '모바일에서 한 칸씩 스와이프. 객실 2~4개일 때 자연스럽다.', - thumb: 'carousel', - Component: RoomsCarousel, - isDefault: true, - }, - { - id: 'rooms.grid', - name: '격자', - description: '전부 한눈에. 객실이 많고 비교해서 고르게 할 때.', - thumb: 'cards', - Component: RoomsGrid, - }, - { - id: 'rooms.list', - name: '가로 목록', - description: '사진 왼쪽 · 설명 오른쪽. 객실 설명이 길 때.', - thumb: 'list', - Component: RoomsList, - }, - ], - - - booking: [ - { - id: 'booking.card', - name: '안내 카드', - description: '가운데 놓인 조용한 카드. 흐름을 끊지 않는다.', - thumb: 'centered', - Component: BookingCard, - isDefault: true, - }, - { - id: 'booking.banner', - name: '띠배너', - description: '브랜드 색으로 화면을 가로지르는 강한 유도.', - thumb: 'banner', - Component: BookingBanner, - }, - ], - - menu: [ - { - id: 'menu.list', - name: '메뉴판', - description: '이름과 가격을 한 줄씩. 종이 메뉴판처럼.', - thumb: 'list', - Component: MenuList, - isDefault: true, - }, - { - id: 'menu.grid', - name: '카드 격자', - description: '설명을 넉넉히. 시그니처가 몇 개뿐일 때.', - thumb: 'cards', - Component: MenuGrid, - }, - { - id: 'menu.price-table', - name: '가격표', - description: '점선으로 이름과 가격만. 품목이 많을 때.', - thumb: 'table', - Component: MenuPriceTable, - }, - ], - - space: [ - { - id: 'space.zones', - name: '구역 카드', - description: '사진과 함께 자리별로. 좌석 구성이 궁금한 곳에.', - thumb: 'cards', - Component: SpaceZones, - isDefault: true, - }, - { - id: 'space.list', - name: '목록', - description: '사진 없이 이름과 설명만. 사진이 부실할 때.', - thumb: 'list', - Component: SpaceList, - }, - ], - - inquiry: [ - { - id: 'inquiry.cta', - name: '연락 카드', - description: '전화 · 카카오로 바로. 폼을 받을 준비가 안 됐을 때.', - thumb: 'centered', - Component: InquiryCta, - isDefault: true, - }, - { - id: 'inquiry.form', - name: '문의 양식', - description: '인원 · 일정 · 연락처를 받는 신청서.', - thumb: 'form', - Component: InquiryForm, - }, - ], - - programs: [ - { - id: 'programs.cards', - name: '카드', - description: '소요시간 · 대상까지 한 장씩. 처음 오는 손님이 고르기 쉽다.', - thumb: 'cards', - Component: ProgramsCards, - isDefault: true, - }, - { - id: 'programs.table', - name: '비교표', - description: '시간 · 대상 · 가격을 열로. 조건을 견줘 고르는 곳에.', - thumb: 'table', - Component: ProgramsTable, - }, - ], - - social: [{id: 'social.cards', name: '함께 발행한 소식', description: '승인해 SNS에 게재한 최신 소식. 홈페이지 맨 아래에 표시합니다.', thumb: 'cards', Component: SocialCanvasSection, isDefault: true}], - songs: [ - { - id: 'songs.turntable', - name: '턴테이블', - description: '도넛판을 눌러 얹으면 판이 돈다. 곡 3~8개일 때 가장 좋다.', - thumb: 'carousel', - Component: SongsTurntable, - isDefault: true, - }, - ], - - daily: [ - { - id: 'daily.calendar', - name: '일력', - description: '오늘 날짜에 맞는 한 장이 자동으로 펼쳐진다. 매일 바뀌니 다시 올 이유가 된다.', - thumb: 'compact', - Component: DailyCalendar, - isDefault: true, - }, - ], - - - - people: [ - { - id: 'people.filmstrip', - name: '필름 스트립', - description: '프레임 하나가 인물 하나. 사진이 없으면 이름 활자가 대신 들어간다.', - thumb: 'carousel', - Component: PeopleFilmstrip, - isDefault: true, - }, - ], - - chronicle: [ - { - id: 'chronicle.rail', - name: '가로 연표', - description: '연도가 큰 활자로 서고 사건이 붙는다. 붉은 점이 도시를 바꾼 해다.', - thumb: 'timeline', - Component: ChronicleRail, - isDefault: true, - }, - ], - - - reading: [ - { - id: 'reading.posts', - name: '읽기 카드', - description: '한 슬라이드에 한 꼭지. 발행본은 이 중 5~6개만 매번 무작위로 세운다.', - thumb: 'carousel', - Component: ReadingRail, - isDefault: true, - }, - ], - - postcard: [ - { - id: 'postcard.stack', - name: '엽서 뒷면', - description: '우표 자리와 소인이 찍힌 뒷면 한 장. [복사] 로 손님이 그대로 퍼 간다.', - thumb: 'carousel', - Component: PostcardStack, - isDefault: true, - }, - ], - - quiz: [ - { - id: 'quiz.flip', - name: '시험지 플립', - description: '앞면은 질문, 뒤집으면 힌트와 출처. 정답은 두지 않는다.', - thumb: 'cards', - Component: QuizFlip, - isDefault: true, - }, - ], - - - exhibition: [ - { - id: 'exhibition.gallery', - name: '전시 갤러리', - description: '전시 사진을 크게 깔고 관람 정보를 붙인다.', - thumb: 'grid', - Component: ExhibitionGallery, - isDefault: true, - }, - { - id: 'exhibition.notice', - name: '관람 공지', - description: '사진 없이 안내 문구만. 일정이 자주 바뀌는 곳에.', - thumb: 'timeline', - Component: ExhibitionNotice, - }, - ], - - video: [ - { - id: 'video.frame', - name: '영상 한 편', - description: '표지를 먼저 보여주고 누르면 재생한다. 세로 영상(쇼츠)은 세로 틀로 나간다.', - thumb: 'fullbleed', - Component: VideoFrame, - isDefault: true, - }, - ], - - itinerary: [ - { - id: 'itinerary.tickets', - name: '승차권', - description: '절취선 뚫린 표가 줄줄이. 표 한 장이 정거장 하나이고 마지막에 완주 도장이 찍힌다.', - thumb: 'carousel', - Component: ItineraryTickets, - isDefault: true, - }, - ], -}; - -/** 그 업종에서 고를 수 있는 배리에이션. 업종 제한이 걸린 건 걸러낸다. */ -export function variantsFor(sectionType: string, industry: IndustryType): SectionVariant[] { - const all = SECTION_VARIANTS[sectionType] ?? []; - return all.filter((v) => !v.industries || v.industries.includes(industry)); -} - -/** 그 타입의 기본 배리에이션. isDefault 가 없으면 첫 번째. */ -export function defaultVariant(sectionType: string): SectionVariant | undefined { - const all = SECTION_VARIANTS[sectionType] ?? []; - return all.find((v) => v.isDefault) ?? all[0]; -} - -/** - * 실제로 그릴 배리에이션. - * - * 저장된 variantId 가 지워졌거나(배리에이션을 뺐거나) 업종이 바뀌어 못 쓰게 됐어도 - * 기본값으로 떨어진다 — 빈 화면 대신 무언가는 반드시 그린다. - */ -export function resolveVariant( - section: SectionItem, - industry: IndustryType, -): SectionVariant | undefined { - const available = variantsFor(section.type, industry); - const picked = available.find((v) => v.id === section.variantId); - return picked ?? defaultVariant(section.type); -} diff --git a/solution/frontend/src/features/builder/canvas/types.ts b/solution/frontend/src/features/builder/canvas/types.ts deleted file mode 100644 index f1dbd4e..0000000 --- a/solution/frontend/src/features/builder/canvas/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 캔버스 배리에이션 계약. - * - * 섹션 하나 = "무엇을 보여줄지"(데이터) + "어떻게 보여줄지"(배리에이션). - * 데이터는 이 props 로 고정하고, 배리에이션만 갈아끼운다 — - * 그래야 사장님이 레이아웃을 바꿔도 내용이 날아가지 않는다. - */ -import type {ComponentType, ReactNode} from 'react'; -import type { - IndustryType, - InfoField, - PhotoItem, - SectionItem, - TemplateItem, -} from '@o2o/shared'; -import type {WeatherLocation} from '@/stores/builder'; - -/** 모든 배리에이션이 동일하게 받는 입력. 배리에이션은 이 밖의 것을 요구하지 않는다. */ -export interface SectionRenderProps { - /** 섹션 메타(id · type · name · variantId). 프레임의 앵커 id 와 배지 라벨이 여기서 나온다. */ - section: SectionItem; - industryId: IndustryType; - storeName: string; - location: string; - weatherLocation?: WeatherLocation; - template: TemplateItem; - infoFields: InfoField[]; - photos: PhotoItem[]; - /** 에디터에서 선택된 섹션인가(미리보기 모드면 항상 false). */ - isSelected: boolean; - onSelect: () => void; -} - -export type SectionVariantComponent = ComponentType; - -/** 디자인 탭이 카드로 그리는 배리에이션 한 칸. */ -export interface SectionVariant { - /** 전역 유일. `.` 규칙 — 저장된 값이 어느 섹션 것인지 눈으로 읽힌다. */ - id: string; - /** 사장님에게 보이는 이름. 개발 용어 말고 "왼쪽 사진 · 오른쪽 글" 같은 말로 쓴다. */ - name: string; - /** 언제 이걸 고르면 좋은지 한 줄. */ - description: string; - /** 와이어프레임 썸네일 키(`thumbs.tsx`). 글보다 그림이 빨리 읽힌다. */ - thumb: ThumbKey; - Component: SectionVariantComponent; - /** 특정 업종에서만 의미 있는 배리에이션이면 여기로 좁힌다. 비우면 전 업종 공통. */ - industries?: IndustryType[]; - /** '기본'으로 표시할 배리에이션. 타입당 하나. */ - isDefault?: boolean; -} - -export type ThumbKey = - | 'fullbleed' - | 'split' - | 'stack' - | 'centered' - | 'grid' - | 'masonry' - | 'carousel' - | 'list' - | 'table' - | 'cards' - | 'accordion' - | 'two-column' - | 'banner' - | 'form' - | 'timeline' - | 'compact'; - -/** 섹션 프레임의 배경 톤. 배리에이션마다 다른 톤을 골라 리듬을 만든다. */ -export type SectionTone = 'paper' | 'white' | 'tint' | 'dark'; - -export interface SectionFrameSlots { - children: ReactNode; -} diff --git a/solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx b/solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx deleted file mode 100644 index a288c1c..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 예약 · 띠배너 — 브랜드 색으로 화면을 가로지르는 강한 전환 유도. - * 예약이 유일한 목표인 사이트에 맞는다(대신 자주 쓰면 광고처럼 보인다). - * - * ★ 문구에서 "실시간 캘린더" 를 걷어냈다(2026-09-09). 우리는 실시간 재고를 갖지 않는다 - * (PRODUCT.md 6절). 발행본이 하는 일 그대로 — 날짜·시간을 고르고 예약 창구로 잇는다 — - * 을 말한다. 에디터가 보여주는 것이 곧 발행될 것이어야 한다. - */ -import {Calendar} from 'lucide-react'; -import {CtaLink, SectionFrame} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {bookingHref} from '../common'; - -export function BookingBanner(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, storeName, template} = props; - - return ( - -
-
-
-

- {section.name} -

-

- {section.body || section.description || `${storeName} · 날짜와 시간을 고르고 예약 창구로 이어집니다`} -

-
- -
- - - 날짜 · 시간 고르기 - -
-
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx b/solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx deleted file mode 100644 index e82fc5a..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx +++ /dev/null @@ -1,148 +0,0 @@ -/** - * 예약 · 카드 — **발행본의 예약 안내를 그대로 미리 보여준다.** - * - * ★ 왜 갈아엎었나 (2026-09-09) - * 이 카드는 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약" 을 그리고 있었다. - * 우리는 실시간 예약을 하지 않는다(PRODUCT.md 6절 — 재고도 결제도 갖지 않는다). - * 게다가 발행본은 날짜·시간을 고르는 화면인데 캔버스만 다른 문구를 보여줘서, - * 사장님이 편집 화면에서 본 것과 발행된 사이트가 서로 다른 물건이었다. - * **에디터가 보여주는 것이 곧 발행될 것**이어야 한다. - * - * ★ 여기서는 **누르지 않는다.** 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라 - * 상태를 가진 위젯을 넣으면 선택과 싸운다. 첫 칸이 골라진 모습으로 고정해 두고, - * 실제 동작은 발행본(`solution/site` StayBookingDemo)이 한다. - * - * ★ 시간 칸은 **체크인 fact 가 있을 때만** 그린다. 발행본과 같은 규칙이다 — - * 체크인이 16:00 인데 미리보기가 14:00 을 보여주면 사장님은 그 선택지가 생긴 줄 안다. - */ -import {CalendarDays, Clock, Minus, Phone, Plus} from 'lucide-react'; -import type {InfoField} from '@o2o/shared'; -import {SectionBody, SectionFrame, SectionHeading} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; - -const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const; - -/** 오늘부터 7칸. 캔버스는 브라우저에서만 도므로 날짜를 그대로 그려도 된다 - * (발행본은 정적 HTML 이라 날짜를 굽지 않는다 — StayBookingDemo 머리주석). */ -function nextDays(count: number) { - const today = new Date(); - return Array.from({length: count}, (_, index) => { - const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + index); - return {day: date.getDate(), weekday: WEEKDAY_LABEL[date.getDay()]}; - }); -} - -/** 확인된 값만 읽는다 — 캔버스도 발행본과 같은 규칙이다. */ -function verified(fields: InfoField[], id: string): string | undefined { - const field = fields.find((f) => f.id === id); - if (!field || field.requiresVerification || !field.value?.trim()) return undefined; - return field.value.trim(); -} - -function arrivalSlots(checkIn?: string): string[] { - const match = /(\d{1,2})\s*:\s*(\d{2})/.exec(checkIn ?? ''); - if (!match) return []; - const start = Number(match[1]); - if (!Number.isFinite(start)) return []; - return Array.from({length: 4}, (_, i) => start + i) - .filter((hour) => hour <= 23) - .map((hour) => `${String(hour).padStart(2, '0')}:${match[2]}`); -} - -export function BookingCard(props: SectionRenderProps) { - const {section, isSelected, onSelect, template, infoFields} = props; - const days = nextDays(7); - const checkIn = verified(infoFields, 'check_in_time'); - const slots = arrivalSlots(checkIn); - const phone = verified(infoFields, 'phone'); - const accent = template.colors.primary; - - return ( - - - - -
-

- - 날짜 · 시간 선택 -

- -
-
-

날짜

-
    - {days.map((day, index) => ( -
  • - {day.weekday} - {day.day} -
  • - ))} -
-
- - {slots.length > 0 && ( -
-

- - 도착 예정 시간 (체크인 {checkIn} 이후) -

-
    - {slots.map((time, index) => ( -
  • - {time} -
  • - ))} -
-
- )} - -
-

인원

-
- - - - 2명 - - - -
-
- -
- 예약 요청 확인하기 -
- - {/* ★ 실제 예약이 되는 창구. 발행본과 같은 순서다 — 데모 다음에 진짜 창구가 온다. */} - {phone && ( -

- - 전화 예약 {phone} -

- )} -
-
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx b/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx deleted file mode 100644 index 186f47d..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/chronicle/ChronicleRail.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/** - * 시간의 골목 — 가로 연표. - * - * 연도가 큰 활자로 서고 사건이 그 아래 붙는다. 레일 위의 **붉은 점은 도시의 성격이 바뀐 해**다 — - * 점의 색이 장식이 아니라 정보라서, 범례를 한 줄 달아 그 약속을 밝힌다. - * ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다. - */ -import {useMemo} from 'react'; -import {Rail, SectionBody, SectionFrame} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {parseSectionData, type ChronicleItem} from '@o2o/shared'; -import { - ITEM_ACCENT, - ITEM_BODY, - ITEM_BORDER, - ITEM_CARD, - ITEM_HEADING, - ITEM_INK, - ParseError, - PasteHint, - SourceLine, -} from '../items/common'; -import '../items/items.css'; - -function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) { - const turning = item.turning === true; - - return ( -
-

- {item.year ?? '연도 미상'} -

- - {/* 레일 — 점이 선 위에 놓여야 '흐르는 시간 위의 한 해'로 읽힌다 */} -
- - -
- -
-

- {item.title} -

- {item.summary && ( -

- {item.summary} -

- )} - {item.place &&

지금 이 자리 · {item.place}

} - -
-
- ); -} - -export function ChronicleRail(props: SectionRenderProps) { - const {section, isSelected, onSelect} = props; - const parsed = parseSectionData(section.type, section.data); - - // 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다. - const items = useMemo( - () => - [...parsed.items].sort((a, b) => { - if (a.year == null) return b.year == null ? 0 : 1; - if (b.year == null) return -1; - return a.year - b.year; - }), - [parsed.items], - ); - - const turningCount = items.filter((item) => item.turning === true).length; - - return ( - - -
-

- {parsed.title || section.name} -

- {(parsed.subtitle || section.description) && ( -

- {parsed.subtitle || section.description} -

- )} -
- - {parsed.error ? ( - - ) : items.length === 0 ? ( - - ) : ( -
-

- - 도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개 -

- - - {items.map((item, index) => ( - - ))} - -
- )} -
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/common.ts b/solution/frontend/src/features/builder/canvas/variants/common.ts deleted file mode 100644 index 8e50472..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/common.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * 배리에이션들이 공유하는 데이터 갈무리. - * - * "어떤 사진을 대표로 쓰나 / 예약 링크는 어디로 가나" 같은 판단은 레이아웃과 무관하다. - * 배리에이션마다 따로 정하면 같은 섹션인데 배리에이션을 바꾼 순간 링크가 달라진다. - * - * ★ 시연용 폴백은 없다. - * 한때 이 파일은 달빛스테이(숙박 시연 세트)와 업종 시드 문구를 폴백으로 들고 있었고, - * `demoOnly()` 게이트가 "실사업장에서만" 그걸 껐다. 그 구조 자체를 걷어냈다 — - * 게이트가 한 곳이라도 빠지면 남의 가게 사진·전화번호·메뉴가 사장님 화면에 뜨고, - * 사장님은 그게 자기 가게 내용인 줄 알고 그대로 발행한다. - * 값이 없으면 **아무것도 그리지 않는다.** 발행 사이트(solution/site)와 같은 규칙이다. - */ -import type {IndustryType, InfoField, PhotoItem} from '@o2o/shared'; -import {INDUSTRY_CONFIGS} from '@/data/industryData'; -import type {TilePhoto} from '../primitives'; - -/** 화면에 찍히는 "언제 기준" 표기. 발행 사이트에서는 payload 의 updatedAt 이 이 자리를 대신한다. */ -export const TODAY_KO = new Date().toLocaleDateString('ko-KR', { - year: 'numeric', - month: 'long', - day: 'numeric', -}); - -/** - * 업종 설정(섹션 목록 · 템플릿 · 채널). - * - * ★ 여기에는 **구조만** 남아 있다. 문구·메뉴·FAQ 같은 콘텐츠 시드는 전부 지웠다 — - * 가공의 가게 이름이 박힌 문장("포레스트 힐은 산자락 아래…")이 실사업장 화면에 - * 자기 소개문처럼 떴기 때문이다. 콘텐츠는 서버(fact·unit·faq)만 소유한다. - */ -export function industryConfig(industryId: IndustryType) { - return INDUSTRY_CONFIGS[industryId]; -} - -/** - * 소개 문구. - * - * 수집·생성된 `intro` fact 만 쓴다. 없으면 빈 값을 돌려주고, 부르는 쪽이 - * "아직 소개문이 없습니다"를 그린다 — 남의 문장으로 자리를 메우지 않는다. - */ -export function introParagraph(_industryId: IndustryType, infoFields: InfoField[]): string { - const collected = infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro'); - return collected?.value?.trim() ?? ''; -} - -/** - * 소개 문구 축약본. `intro`/`room_intro` fact 가 길 때만 백엔드가 채워 보낸다(요청·응답에만 - * 실리고 DB 에는 남지 않는다). 없으면 빈 값 — 부르는 쪽이 원문(introParagraph)으로 떨어진다. - */ -export function introSummary(_industryId: IndustryType, infoFields: InfoField[]): string { - const collected = infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro'); - return collected?.summary?.trim() ?? ''; -} - -/** - * 이용 규정으로 읽히는 fact 들. 순서가 곧 화면 순서다. - * - * ★ 스키마에 있는 key 만 적는다. 여기 없는 규정은 만들지 않는다 — - * 예전에는 '체크인 16:00 / 체크아웃 11:00' 같은 가공의 규정이 상수로 박혀 있어 - * 모든 사업장 화면에 똑같이 떴다. 사장님은 그게 자기 규정인 줄 알고 발행했다. - */ -const RULE_FIELD_IDS = [ - 'check_in_time', - 'check_out_time', - 'cancel_policy', - 'cooking_allowed', - 'pet_allowed', - 'smoking', - 'extra_person_fee', -]; - -/** 확인된 fact 에서만 이용 규정 줄을 만든다. 없으면 빈 목록 — 부르는 쪽이 빈 상태를 그린다. */ -export function ruleItems(infoFields: InfoField[]): string[] { - return RULE_FIELD_IDS.map((id) => infoFields.find((f) => f.id === id)) - .filter((f): f is InfoField => Boolean(f?.value?.trim())) - .map((f) => `${f.label}: ${f.value.trim()}`); -} - -/** 대표 사진. 지정이 없으면 첫 장으로 떨어진다 — 히어로가 빈 채로 나가지 않게. */ -export function primaryPhoto(photos: PhotoItem[]) { - return photos.find((p) => p.isPrimary) ?? photos[0]; -} - -/** 수집된 사진(props)을 갤러리 타일 모양으로. 캡션은 사진 제목을 그대로 쓴다. */ -function toTiles(photos: PhotoItem[]): TilePhoto[] { - return photos.map((p) => ({ - id: p.id, - url: p.url, - alt: p.title, - caption: p.title, - category: p.category, - })); -} - -/** 갤러리에 깔 사진 — 수집된 것만. 없으면 빈 목록이고, 부르는 쪽이 빈 상태를 그린다. */ -export function galleryPhotos(_industryId: IndustryType, photos: PhotoItem[]): TilePhoto[] { - return toTiles(photos.filter((photo) => photo.isVisible !== false)); -} - -/** - * 예약 버튼이 향할 곳. - * - * ★ 실사업장의 예약 채널(place_links)은 SectionRenderProps 로 흘러오지 않는다. - * 그래서 상호 검색으로 떨어뜨린다 — 시연용 예약 링크를 붙이는 것보다 안전하다. - * (확정된 링크를 쓰려면 계약에 채널 URL 을 얹어야 한다.) - */ -export function bookingHref(_industryId: IndustryType, storeName: string) { - return `https://search.naver.com/search.naver?query=${encodeURIComponent(storeName)}`; -} diff --git a/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx b/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx deleted file mode 100644 index 75291b5..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/daily/DailyCalendar.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/** - * 오늘의 한 장 — 뜯어 넘기는 일력. - * - * 이 팩에서 유일하게 캐러셀이 아니다. '오늘'은 하나여야 하니까. 다만 어제·내일이 양옆에 - * 반쯤 걸쳐 있어 넘길 수 있다는 걸 눈으로 알려 준다. - */ -import {useMemo, useState} from 'react'; -import {SectionBody, SectionFrame} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {parseSectionData, type DailyItem} from '@o2o/shared'; -import { - CarouselNav, - ITEM_ACCENT, - ITEM_BODY, - ITEM_BORDER, - ITEM_CARD, - ITEM_HEADING, - ITEM_SURFACE, - ParseError, - PasteHint, - SourceLine, -} from '../items/common'; -import '../items/items.css'; - -const DOW = ['일', '월', '화', '수', '목', '금', '토']; - -function todayMonthDay(): string { - const now = new Date(); - return `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; -} - -/** MM-DD 를 올해 날짜로 읽어 요일을 낸다. 형식이 아니면 undefined — 지어내지 않는다. */ -function dowOf(monthDay: string): string | undefined { - const m = /^(\d{2})-(\d{2})$/.exec(monthDay.trim()); - if (!m) return undefined; - const date = new Date(new Date().getFullYear(), Number(m[1]) - 1, Number(m[2])); - if (Number.isNaN(date.getTime())) return undefined; - return DOW[date.getDay()]; -} - -function CalendarPage({item, muted}: {item: DailyItem; muted?: boolean}) { - const [month, day] = item.monthDay.split('-'); - const dow = dowOf(item.monthDay); - - return ( -
- {/* 뜯어낸 자국 */} -
-
- {[0, 1, 2].map((i) => ( - - ))} -
- -
-

{month}月

-

- {String(Number(day) || day)} -

- {dow &&

{dow}曜

} -
- -
- {item.category && ( -

- {item.category} -

- )} -

- {item.title} -

- {item.body && ( -

- {item.body} -

- )} - {item.tags && item.tags.length > 0 && ( -

{item.tags.join(' ')}

- )} -
- -
-
-
- ); -} - -export function DailyCalendar(props: SectionRenderProps) { - const {section, isSelected, onSelect} = props; - const parsed = parseSectionData(section.type, section.data); - - const items = useMemo( - () => [...parsed.items].sort((a, b) => a.monthDay.localeCompare(b.monthDay)), - [parsed.items], - ); - - // 오늘 날짜가 목록에 없으면 오늘 **이후 첫 장**을 편다 — 지난 날짜를 오늘로 내걸지 않는다. - const startIndex = useMemo(() => { - if (items.length === 0) return 0; - const today = todayMonthDay(); - const exact = items.findIndex((item) => item.monthDay === today); - if (exact >= 0) return exact; - const upcoming = items.findIndex((item) => item.monthDay >= today); - return upcoming >= 0 ? upcoming : 0; - }, [items]); - - const [offset, setOffset] = useState(0); - const index = items.length > 0 ? (startIndex + offset + items.length * 100) % items.length : 0; - const at = (delta: number) => items[(index + delta + items.length) % items.length]; - - return ( - - -
-

- {parsed.title || section.name} -

- {(parsed.subtitle || section.description) && ( -

- {parsed.subtitle || section.description} -

- )} -
- - {parsed.error ? ( - - ) : items.length === 0 ? ( - - ) : ( -
-
- {items.length > 1 && } - - {items.length > 1 && } -
- {items.length > 1 && ( - setOffset((v) => v - 1)} - onNext={() => setOffset((v) => v + 1)} - label="날짜" - /> - )} -

- 오늘 날짜에 맞는 장이 자동으로 펼쳐집니다 · 총 {items.length}장 - {parsed.unverified > 0 && ` · 확인 필요 ${parsed.unverified}장`} -

-
- )} -
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionGallery.tsx b/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionGallery.tsx deleted file mode 100644 index a5e19cb..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionGallery.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 관람 안내 · 갤러리 — 전시 사진을 크게 깔고 관람 정보를 옆에 붙인다. - */ -import {Clock, Ticket} from 'lucide-react'; -import { - FeatureCard, - PhotoTile, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {galleryPhotos} from '../common'; - -export function ExhibitionGallery(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, template, photos, infoFields} = props; - const items = galleryPhotos(industryId, photos).slice(0, 4); - const hours = infoFields.find((f) => f.id === 'operating_hours'); - const fee = infoFields.find((f) => f.id === 'admission' || f.id === 'price_range'); - - return ( - - - - -
- {items.map((photo) => ( - - ))} -
- -
- {hours && ( - - )} - {fee && ( - - )} -
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionNotice.tsx b/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionNotice.tsx deleted file mode 100644 index cc1f896..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/exhibition/ExhibitionNotice.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * 관람 안내 · 공지 — 사진 없이 안내 문구만. 전시 일정이 자주 바뀌는 곳에 부담이 적다. - */ -import {Info} from 'lucide-react'; -import {SectionBody, SectionFrame, SectionHeading} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; - -export function ExhibitionNotice(props: SectionRenderProps) { - const {section, isSelected, onSelect, storeName, template, infoFields} = props; - const notices = infoFields - .filter((f) => !f.requiresVerification || f.isVerified) - .slice(0, 4); - - return ( - - - } - colors={template.colors} - /> - -
-

- {section.body || `${storeName}의 상설 전시와 아트숍은 별도 예약 없이 관람하실 수 있습니다. 기획 전시 일정은 변경될 수 있으니 방문 전 확인해 주세요.`} -

- -
- {notices.map((field) => ( -
-
{field.label}
-
- {field.value} -
-
- ))} -
-
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/FaqAccordion.tsx b/solution/frontend/src/features/builder/canvas/variants/faq/FaqAccordion.tsx deleted file mode 100644 index 5128e48..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/faq/FaqAccordion.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * FAQ · 아코디언 — 질문만 보이고 누르면 답이 열린다. 항목이 많아도 화면이 짧다. - */ -import {useState} from 'react'; -import {ChevronDown, ChevronUp} from 'lucide-react'; -import {ListCard, SectionBody, SectionFrame, SectionHeading} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {faqEntries} from './useFaqList'; - -export function FaqAccordion(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, template} = props; - const faqs = faqEntries(industryId); - const [openIdx, setOpenIdx] = useState(0); - - return ( - - - - - - {faqs.map((faq, idx) => { - const isOpen = openIdx === idx; - - return ( -
- - - {isOpen && ( -
- {faq.answer} -
- )} -
- ); - })} -
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/FaqOpenList.tsx b/solution/frontend/src/features/builder/canvas/variants/faq/FaqOpenList.tsx deleted file mode 100644 index 3241225..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/faq/FaqOpenList.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * FAQ · 펼친 목록 — 질문과 답을 모두 열어 둔다. - * 손님이 클릭 없이 훑고 지나가게 하고 싶을 때(검색 노출에도 유리하다). - */ -import {SectionBody, SectionFrame, SectionHeading} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {faqEntries} from './useFaqList'; - -export function FaqOpenList(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, template} = props; - const faqs = faqEntries(industryId); - - return ( - - - - -
- {faqs.map((faq) => ( -
-
- - Q. - - {faq.question} -
-
- A. - {faq.answer} -
-
- ))} -
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/FaqTwoColumn.tsx b/solution/frontend/src/features/builder/canvas/variants/faq/FaqTwoColumn.tsx deleted file mode 100644 index f3214b1..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/faq/FaqTwoColumn.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * FAQ · 2단 — 넓은 화면에서 두 줄씩 나눠 세로 길이를 반으로 줄인다. - */ -import {SectionBody, SectionFrame, SectionHeading} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {faqEntries} from './useFaqList'; - -export function FaqTwoColumn(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, template} = props; - const faqs = faqEntries(industryId); - - return ( - - - - -
- {faqs.map((faq) => ( -
-
- {faq.question} -
-
{faq.answer}
-
- ))} -
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/faq/useFaqList.ts b/solution/frontend/src/features/builder/canvas/variants/faq/useFaqList.ts deleted file mode 100644 index 0d5039c..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/faq/useFaqList.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {IndustryType} from '@o2o/shared'; - -export interface FaqEntry { - question: string; - answer: string; -} - -/** - * FAQ 원본. - * - * ★ 지금은 항상 빈 목록이다. 캔버스 계약(SectionRenderProps)에 FAQ 가 없기 때문이다 — - * 에디터는 구조적으로 실제 FAQ 를 받을 창구가 없다. - * - * ★ 업종 시드 FAQ 를 폴백으로 쓰던 코드를 걷어냈다. 그 안에는 가공의 값이 가격까지 붙어 있었고 - * ("숯과 그릴 세트(25,000원)", "기준 2인 초과 시 1인당 30,000원"), 사장님이 팔지도 않는 - * 조건을 자기 사이트로 읽었다. 손님이 그 금액으로 오면 클레임이다. - * - * 진짜 FAQ 는 COPY 잡이 **확인된 fact 만 근거로** 만들고 사장님 승인을 거쳐 - * 서버(fact.faqs)에 들어간다 — 그게 유일한 출처다. 캔버스로 흘려보내려면 - * SectionRenderProps 에 faqs 를 얹어야 한다. - */ -export function faqEntries(_industryId: IndustryType): FaqEntry[] { - return []; -} diff --git a/solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx b/solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx deleted file mode 100644 index 3414102..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/hero/HeroCover.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 히어로 · 표지 — 사진 한 장 위, 글은 좌하단. - * - * ★ 왜 가운데가 아닌가 - * 사진이 주인공인 화면에서 글자를 한가운데 얹으면 피사체를 정확히 가린다. 숙소 사진은 - * 가운데에 방이나 사람이 오는데, 상호가 그 위에 앉으면 둘 다 못 읽는다. 아래로 내리고 - * 그쪽만 어둡게 덮으면 사진은 사진대로 남고 글자는 글자대로 읽힌다. - * - * ★ 버튼을 세우지 않는다 - * 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다. 예약 버튼은 아래 예약 섹션이 맡고, - * 여기서는 다음 섹션으로 눈을 내려보내기만 한다. - * - * ★ 색을 직접 쓰지 않는다 - * `bg-stone-900` 같은 고정색을 두면 '옛 항구'(갱지)를 골라도 첫 화면만 검게 남는다. - * 어두운 면은 `--tpl-inverse`, 그 위 글자는 `--tpl-bg` 다 — 팔레트가 바뀌면 같이 바뀐다. - */ -import {ChevronDown, MapPin} from 'lucide-react'; -import {SectionFrame} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {primaryPhoto} from '../common'; - -/** 사진 위 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */ -const SCRIM = - 'linear-gradient(to top,' + - ' color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' + - ' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%,' + - ' transparent 66%)'; - -export function HeroCover(props: SectionRenderProps) { - const {section, isSelected, onSelect, storeName, location, photos} = props; - - // 사진이 없으면 없이 어두운 판만 남긴다 — 시연용 사진으로 자리를 메우지 않는다. - const cover = primaryPhoto(photos); - - return ( - -
- {cover?.url && ( -
- {storeName} -
-
- )} - -
- {location && ( -

- - {location} -

- )} - - {/* ★ tpl-title — 굵기를 템플릿이 정한다. 간판체(Gugi)는 굵기가 한 벌뿐이라 - 700 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. */} -

- {storeName} -

- - {/* - * ★ 여기에 `section.description` 을 쓰지 않는다. - * 그 칸은 에디터의 섹션 설명("상단 메인 비주얼과 대표 문구")이라, 캔버스에 그리면 - * 사장님 화면에 우리 UI 안내문이 자기 소개문처럼 박힌다. 실제로 그렇게 나갔다. - * 대표 문구는 수집·생성된 값이 생기기 전까지 **아무것도 그리지 않는다.** - */} - -

- 객실 보기 - -

-
-
- - ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/hero/HeroSplit.tsx b/solution/frontend/src/features/builder/canvas/variants/hero/HeroSplit.tsx deleted file mode 100644 index f4d4e64..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/hero/HeroSplit.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 히어로 · 좌우 분할 — 왼쪽 글, 오른쪽 사진. - * 첫 화면에서 "무엇을 파는 곳인지"를 글로 먼저 읽혀야 하는 업종에 맞는다. - */ -import {SectionFrame} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {industryConfig, primaryPhoto} from '../common'; - -export function HeroSplit(props: SectionRenderProps) { - const {section, isSelected, onSelect, industryId, storeName, location, template, photos} = props; - const config = industryConfig(industryId); - const cover = primaryPhoto(photos); - - return ( - -
-
-
- - - {section.description || `${config.name} · ${location}`} - - -

- {storeName} -

-
- -
-
- {storeName} -
-
-
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/info/InfoCards.tsx b/solution/frontend/src/features/builder/canvas/variants/info/InfoCards.tsx deleted file mode 100644 index 720cc94..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/info/InfoCards.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 기본 정보 · 카드 — 항목마다 한 장씩. 모바일에서 손가락으로 훑기 좋다. - */ -import { - HiddenFieldsNotice, - InfoRow, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {TODAY_KO} from '../common'; - -export function InfoCards(props: SectionRenderProps) { - const {section, isSelected, onSelect, template, infoFields} = props; - const visible = infoFields.filter((f) => !f.requiresVerification || f.isVerified); - const hidden = infoFields.filter((f) => f.requiresVerification && !f.isVerified).length; - - return ( - - - } - colors={template.colors} - /> - -
- {visible.map((field) => ( - - ))} -
- -
- * {TODAY_KO} 기준 정보입니다. -
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/info/InfoInline.tsx b/solution/frontend/src/features/builder/canvas/variants/info/InfoInline.tsx deleted file mode 100644 index 450ea0d..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/info/InfoInline.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 기본 정보 · 한 줄 요약 — 값들을 문장처럼 이어 붙인 가장 좁은 형태. - * 정보가 적거나, 아래 섹션으로 빨리 넘기고 싶을 때. - */ -import { - HiddenFieldsNotice, - InfoRow, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {TODAY_KO} from '../common'; - -export function InfoInline(props: SectionRenderProps) { - const {section, isSelected, onSelect, template, infoFields} = props; - const visible = infoFields.filter((f) => !f.requiresVerification || f.isVerified); - const hidden = infoFields.filter((f) => f.requiresVerification && !f.isVerified).length; - - return ( - - - } - colors={template.colors} - /> - -
- {visible.map((field) => ( - - ))} -
- - * {TODAY_KO} 기준 -
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/info/InfoTable.tsx b/solution/frontend/src/features/builder/canvas/variants/info/InfoTable.tsx deleted file mode 100644 index 6980d41..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/info/InfoTable.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 기본 정보 · 표 — 라벨과 값을 선으로 나눈 가장 읽기 쉬운 형태. - * 항목이 많아도 눈이 안 흔들린다. - */ -import { - HiddenFieldsNotice, - InfoRow, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; -import {TODAY_KO} from '../common'; - -export function InfoTable(props: SectionRenderProps) { - const {section, isSelected, onSelect, template, infoFields} = props; - const visible = infoFields.filter((f) => !f.requiresVerification || f.isVerified); - const hidden = infoFields.filter((f) => f.requiresVerification && !f.isVerified).length; - - return ( - - - } - colors={template.colors} - /> - -
- {visible.map((field) => ( - - ))} -
- -
- * {TODAY_KO} 기준 정보입니다. -
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryCta.tsx b/solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryCta.tsx deleted file mode 100644 index fe5aede..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryCta.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 문의 · 연락 카드 — 전화 / 카카오 / 인스타로 바로 보낸다. - * 폼을 받을 준비가 안 된 사장님에게는 이쪽이 사고가 없다. - */ -import {Phone} from 'lucide-react'; -import { - EmptyStateNotice, - SectionBody, - SectionFrame, - SectionHeading, -} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; - -export function InquiryCta(props: SectionRenderProps) { - const {section, isSelected, onSelect, storeName, template} = props; - // ★ 연락처(place_links)는 SectionRenderProps 로 흘러오지 않는다. 값이 생기기 전까지 - // 버튼째 숨긴다 — 시연용 번호를 붙이면 남의 가게로 전화가 간다. - const phone = props.infoFields.find((f) => f.id === 'phone')?.value?.trim(); - - return ( - - - - -
-

- {section.body || `${storeName} 단체·대관 문의는 아래로 연락 주시면 담당자가 안내해 드립니다.`} -

- - {phone ? ( - - ) : ( - 연락처는 아직 준비 중입니다. - )} -
-
-
- ); -} diff --git a/solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryForm.tsx b/solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryForm.tsx deleted file mode 100644 index 3c2321c..0000000 --- a/solution/frontend/src/features/builder/canvas/variants/inquiry/InquiryForm.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 문의 · 신청서 — 인원/일정/연락처를 받는 양식. - * - * ★ 캔버스는 미리보기다. 여기서 제출은 실제로 나가지 않는다 — - * 그 사실을 화면에 적어 둬야 사장님이 "접수됐겠지" 하고 넘어가지 않는다. - */ -import {Send} from 'lucide-react'; -import {SectionBody, SectionFrame, SectionHeading} from '../../primitives'; -import type {SectionRenderProps} from '../../types'; - -const FIELDS = [ - {id: 'name', label: '성함 / 단체명', placeholder: '홍길동 / ○○기업 총무팀'}, - {id: 'contact', label: '연락처', placeholder: '010-0000-0000'}, - {id: 'date', label: '희망 일자', placeholder: '2026-09-14'}, - {id: 'headcount', label: '예상 인원', placeholder: '20명'}, -]; - -export function InquiryForm(props: SectionRenderProps) { - const {section, isSelected, onSelect, template} = props; - - return ( - - - - -
e.stopPropagation()} - role="presentation" - className="space-y-3 rounded-2xl border border-stone-200/80 bg-white p-5 shadow-2xs" - > -
- {FIELDS.map((field) => ( - - ))} -
- -