From 479edf94034b6d25bd718f2c4beb901ba5f2b36c Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:37:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[feat]=20solution/backend:=20=EB=82=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=AA=A9=EB=A1=9D=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=E2=80=94=20places=20LEF?= =?UTF-8?q?T=20JOIN=20sites=20=EB=8B=A8=EC=9D=BC=20=EC=A7=88=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인한 사장님이 자기 사이트를 볼 화면이 없었다. 사이트는 place_id 로 한 건씩만 읽혀서 (site_crud.get_site_by_place) 사업장 목록으로 그리면 줄마다 사이트를 다시 물어 N+1 이 된다. - site_crud.list_company_sites: places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사이트가 아직 없는 사업장(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다 - protocol.MySiteData: 한 줄 = 사업장 + 사이트. render(정적 파일 존재)는 넣지 않았다 — 보고서 파일을 읽는 값이라 줄 수만큼 파일 IO 가 된다. 단건(Res_Site)이 계속 소유한다 - site_service.list_my_sites: 회사 스코프. needs_rebuild 는 단건과 같은 규칙으로 판정한다 - GET /v1/site/list 는 라우터 객체를 따로 둔다 — 기존 라우터는 접두어에 place_id 가 박혀 있다 테스트 5건 추가(비어 있는 사업장·조인·회사 격리·재빌드 일치·비로그인), 539 passed (기존 실패 4건은 이 변경 전에도 같다 — build_publish 3 · snapshot 1) --- solution/backend/crud/site_crud.py | 36 ++++++- solution/backend/router/router.py | 1 + solution/backend/router/v1/site/protocol.py | 39 +++++++- solution/backend/router/v1/site/site.py | 22 ++++- solution/backend/services/site_service.py | 42 +++++++- solution/backend/tests/test_my_sites.py | 90 +++++++++++++++++ .../frontend/src/api/generated/model/index.ts | 11 +++ .../api/generated/model/listMySitesParams.ts | 18 ++++ .../src/api/generated/model/mySiteData.ts | 36 +++++++ .../generated/model/mySiteDataCreatedAt.ts | 8 ++ .../api/generated/model/mySiteDataDomain.ts | 8 ++ .../generated/model/mySiteDataPublishedAt.ts | 8 ++ .../generated/model/mySiteDataRoadAddress.ts | 8 ++ .../api/generated/model/mySiteDataSiteId.ts | 8 ++ .../api/generated/model/mySiteDataStatus.ts | 9 ++ .../generated/model/mySiteDataTemplateId.ts | 8 ++ .../src/api/generated/model/reqSiteTheme.ts | 6 +- .../src/api/generated/model/resMySites.ts | 18 ++++ .../src/api/generated/model/resMySitesMsg.ts | 8 ++ .../frontend/src/api/generated/site/site.ts | 99 ++++++++++++++++++- 20 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 solution/backend/tests/test_my_sites.py create mode 100644 solution/frontend/src/api/generated/model/listMySitesParams.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteData.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataDomain.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataSiteId.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataStatus.ts create mode 100644 solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts create mode 100644 solution/frontend/src/api/generated/model/resMySites.ts create mode 100644 solution/frontend/src/api/generated/model/resMySitesMsg.ts diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index 926712c..9910803 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 publish_logs, site_versions, sites +from common.database.model.models import places, publish_logs, site_versions, sites from common.enums import BuildStatus, ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -21,6 +21,10 @@ class ISiteCRUD(ABC): async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]: pass + @abstractmethod + async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]: + pass + @abstractmethod async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: pass @@ -85,6 +89,36 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: + """회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). + + 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 + (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.""" + try: + where = and_(places.deleted == False, places.company_id == company_id) # 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) + .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/router/router.py b/solution/backend/router/router.py index 462f484..1b0aaaf 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -87,5 +87,6 @@ app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.media.media.router) app.include_router(router.v1.job.job.router) app.include_router(router.v1.site.site.router) +app.include_router(router.v1.site.site.my_router) app.include_router(router.v1.local.local.router) app.include_router(router.v1.local.local.weather_router) diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 6574fb1..8687ea7 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -4,8 +4,17 @@ from typing import Any, Optional from pydantic import ConfigDict -from common.enums import BuildStatus, JobStatus, PublishAction, PublishRejectReason, PublishResult, SiteStatus -from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol +from common.enums import ( + BuildStatus, + JobStatus, + PlaceCategory, + PlaceStatus, + PublishAction, + PublishRejectReason, + PublishResult, + SiteStatus, +) +from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol class SiteProtocol(WebPacketProtocol): @@ -56,6 +65,32 @@ class SiteData(WebPacketProtocol): published_at: Optional[datetime] = None +class MySiteData(WebPacketProtocol): + """내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). + + ★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유). + ★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다.""" + + place_id: uuid.UUID + name: str + category: PlaceCategory + place_status: PlaceStatus + road_address: Optional[str] = None + created_at: Optional[datetime] = None + + site_id: Optional[uuid.UUID] = None + status: Optional[SiteStatus] = None + domain: Optional[str] = None + template_id: Optional[str] = None + published_at: Optional[datetime] = None + # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. + needs_rebuild: bool = False + + +class Res_MySites(Res_PageProtocol): + sites: list[MySiteData] = [] + + class PublishLogData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index d34fd59..b9cb12f 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -2,7 +2,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query -from common.models.gmodel import UserInfo +from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.site_service import SiteService from .protocol import ( @@ -11,6 +11,7 @@ from .protocol import ( Req_SiteTemplate, Req_SiteTheme, Req_StartBuild, + Res_MySites, Res_PublishLogs, Res_Site, Res_SeoAudit, @@ -23,6 +24,25 @@ from .protocol import ( # 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다. router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}}) +# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어 +# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록). +my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}}) + + +@my_router.get( + path="/list", + response_model=Res_MySites, + summary="내 사이트 목록", + description="로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 " + "site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.", +) +async def list_my_sites( + service: SiteService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + pg: PageParams = Depends(), +): + return RemoveNoneResponse(await service.list_my_sites(user_info, pg)) + @router.get( path="", diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 5b1e89e..3103797 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -16,12 +16,13 @@ from common.enums import ( SiteStatus, ) from common.logger import LOG -from common.models.gmodel import UserInfo +from common.models.gmodel import PageParams, UserInfo from common.utils.gtime import GTime from crud.job_crud import JobQueue from crud.place_crud import PlaceCRUD from crud.site_crud import ISiteCRUD, SiteCRUD from router.v1.site.protocol import ( + MySiteData, PublishLogData, RenderStatusData, Req_SiteSlug, @@ -29,6 +30,7 @@ from router.v1.site.protocol import ( Req_SiteTemplate, Req_SiteTheme, Req_StartBuild, + Res_MySites, Res_PublishLogs, Res_Site, Res_SeoAudit, @@ -417,6 +419,44 @@ class SiteService: 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) + cid = uuid.UUID(user_info.company_id) + err_type, rows, total = await DB_SESSION_MNG.execute_lambda( + places.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows] + res.total = total + return res + + @staticmethod + def _my_site_row(place, site, built_at) -> MySiteData: + # ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다. + changed = place.content_updated_at + return MySiteData( + place_id=place.place_id, + name=place.name, + category=place.category, + place_status=place.status, + road_address=place.road_address or place.address, + created_at=place.created_at, + site_id=getattr(site, "site_id", None), + status=getattr(site, "status", None), + domain=getattr(site, "domain", None), + template_id=getattr(site, "template_id", None), + published_at=getattr(site, "published_at", None), + needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), + ) + async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site: res = Res_Site() err_type, place = await self._load_place(user_info, place_id) diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py new file mode 100644 index 0000000..3ad532b --- /dev/null +++ b/solution/backend/tests/test_my_sites.py @@ -0,0 +1,90 @@ +"""내 사이트 목록 — 로그인한 사장님이 자기 사이트 전부를 보는 화면의 뒷단. + +이 경로가 절대 하면 안 되는 것: + - 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면 + 사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다). + - 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. + - 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른 + 답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다. +""" +import uuid + +from sqlalchemy import text + +from common.enums import ErrorType, SiteStatus + + +async def _place(client, headers, name): + r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1}) + return r.json()["place"]["place_id"] + + +async def _list(client, headers, **params): + return (await client.get("/v1/site/list", headers=headers, params=params)).json() + + +async def test_place_without_site_is_still_listed(auth_headers, client): + """검증: 사이트 행이 없는 사업장(위저드만 걸어온 것)도 목록에 나온다. + 기대결과: 줄은 있고 site_id 는 없다 — 화면이 '만드는 중'으로 그릴 근거다.""" + h = await auth_headers("my1") + await _place(client, h, "아직펜션") + + body = await _list(client, h) + assert body["result"]["code"] == ErrorType.SUCCESS.value + assert body["total"] == 1 + row = body["sites"][0] + assert row["name"] == "아직펜션" + assert row.get("site_id") is None + assert row.get("status") is None + + +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/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["domain"] == "joined-stay" + assert row["status"] == SiteStatus.DRAFT.value + + +async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id): + """검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다. + 기대결과: 각자 자기 것만 1건.""" + mine = await auth_headers("my3") + theirs = await auth_headers("my3b", other_company_id) + await _place(client, mine, "내펜션") + await _place(client, theirs, "남의펜션") + + assert [r["name"] for r in (await _list(client, mine))["sites"]] == ["내펜션"] + assert [r["name"] for r in (await _list(client, theirs))["sites"]] == ["남의펜션"] + + +async def test_needs_rebuild_matches_the_single_site_answer(auth_headers, client, db_engine): + """검증: 재빌드 판정이 단건 조회와 같은 답을 낸다. + 기대결과: 노출값이 바뀐 사업장은 목록에서도 needs_rebuild=true.""" + 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"}) + async with db_engine.begin() as conn: + await conn.execute( + text("UPDATE places SET content_updated_at = now() WHERE place_id = :pid"), + {"pid": uuid.UUID(pid)}, + ) + + single = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() + row = (await _list(client, h))["sites"][0] + assert row["needs_rebuild"] is True + assert row["needs_rebuild"] == single["needs_rebuild"] + + +async def test_list_requires_login(client): + """검증: 내 것을 보는 화면이므로 토큰 없이는 열리지 않는다. + 기대결과: 401.""" + assert (await client.get("/v1/site/list")).status_code == 401 diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index 2851bae..87864fa 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -54,6 +54,7 @@ export * from './listFactsParams'; export * from './listFaqsParams'; export * from './listLinksParams'; export * from './listMediaParams'; +export * from './listMySitesParams'; export * from './listPlacesParams'; export * from './localContentData'; export * from './localContentDataBody'; @@ -77,6 +78,14 @@ export * from './mediaDataUnitId'; export * from './mediaDataVisionConfidence'; export * from './mediaDataWidth'; export * from './mediaStatus'; +export * from './mySiteData'; +export * from './mySiteDataCreatedAt'; +export * from './mySiteDataDomain'; +export * from './mySiteDataPublishedAt'; +export * from './mySiteDataRoadAddress'; +export * from './mySiteDataSiteId'; +export * from './mySiteDataStatus'; +export * from './mySiteDataTemplateId'; export * from './placeCandidate'; export * from './placeCandidateAddress'; export * from './placeCandidateCategoryName'; @@ -215,6 +224,8 @@ export * from './resMeMsg'; export * from './resMeName'; export * from './resMediaList'; export * from './resMediaListMsg'; +export * from './resMySites'; +export * from './resMySitesMsg'; export * from './resPlace'; export * from './resPlaceList'; export * from './resPlaceListMsg'; diff --git a/solution/frontend/src/api/generated/model/listMySitesParams.ts b/solution/frontend/src/api/generated/model/listMySitesParams.ts new file mode 100644 index 0000000..9e04322 --- /dev/null +++ b/solution/frontend/src/api/generated/model/listMySitesParams.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 ListMySitesParams = { +/** + * @minimum 1 + */ +page?: number; +/** + * @minimum 1 + * @maximum 100 + */ +size?: number; +}; diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts new file mode 100644 index 0000000..cb463b1 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -0,0 +1,36 @@ +/** + * 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 { MySiteDataRoadAddress } from './mySiteDataRoadAddress'; +import type { MySiteDataCreatedAt } from './mySiteDataCreatedAt'; +import type { MySiteDataSiteId } from './mySiteDataSiteId'; +import type { MySiteDataStatus } from './mySiteDataStatus'; +import type { MySiteDataDomain } from './mySiteDataDomain'; +import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; +import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; + +/** + * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). + +★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유). +★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다. + */ +export interface MySiteData { + place_id: string; + name: string; + category: PlaceCategory; + place_status: PlaceStatus; + road_address?: MySiteDataRoadAddress; + created_at?: MySiteDataCreatedAt; + site_id?: MySiteDataSiteId; + status?: MySiteDataStatus; + domain?: MySiteDataDomain; + template_id?: MySiteDataTemplateId; + published_at?: MySiteDataPublishedAt; + needs_rebuild?: boolean; +} diff --git a/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts b/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.ts new file mode 100644 index 0000000..3ae245a --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataCreatedAt.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 MySiteDataCreatedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataDomain.ts b/solution/frontend/src/api/generated/model/mySiteDataDomain.ts new file mode 100644 index 0000000..f9010e9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataDomain.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 MySiteDataDomain = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts b/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.ts new file mode 100644 index 0000000..1b2b7b5 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataPublishedAt.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 MySiteDataPublishedAt = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts b/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.ts new file mode 100644 index 0000000..2f5fd1a --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataRoadAddress.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 MySiteDataRoadAddress = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts b/solution/frontend/src/api/generated/model/mySiteDataSiteId.ts new file mode 100644 index 0000000..56035c0 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataSiteId.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 MySiteDataSiteId = string | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataStatus.ts b/solution/frontend/src/api/generated/model/mySiteDataStatus.ts new file mode 100644 index 0000000..66b4a78 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataStatus.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 MySiteDataStatus = SiteStatus | null; diff --git a/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts b/solution/frontend/src/api/generated/model/mySiteDataTemplateId.ts new file mode 100644 index 0000000..8bd2686 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataTemplateId.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 MySiteDataTemplateId = string | null; diff --git a/solution/frontend/src/api/generated/model/reqSiteTheme.ts b/solution/frontend/src/api/generated/model/reqSiteTheme.ts index f6fa1ae..eff465b 100644 --- a/solution/frontend/src/api/generated/model/reqSiteTheme.ts +++ b/solution/frontend/src/api/generated/model/reqSiteTheme.ts @@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme'; 떨어뜨리므로 화면은 깨지지 않는다. ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: - {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} - sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 - (별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. + {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}} + sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 + (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다. pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — 서버는 배달부지 심판이 아니다. diff --git a/solution/frontend/src/api/generated/model/resMySites.ts b/solution/frontend/src/api/generated/model/resMySites.ts new file mode 100644 index 0000000..f8c5655 --- /dev/null +++ b/solution/frontend/src/api/generated/model/resMySites.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 { ResMySitesMsg } from './resMySitesMsg'; +import type { MySiteData } from './mySiteData'; + +export interface ResMySites { + result?: ErrorInfo; + msg?: ResMySitesMsg; + total?: number; + page?: number; + size?: number; + sites?: MySiteData[]; +} diff --git a/solution/frontend/src/api/generated/model/resMySitesMsg.ts b/solution/frontend/src/api/generated/model/resMySitesMsg.ts new file mode 100644 index 0000000..4cb7a4c --- /dev/null +++ b/solution/frontend/src/api/generated/model/resMySitesMsg.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 ResMySitesMsg = string | null; diff --git a/solution/frontend/src/api/generated/site/site.ts b/solution/frontend/src/api/generated/site/site.ts index 9cdbd7c..c44b8ee 100644 --- a/solution/frontend/src/api/generated/site/site.ts +++ b/solution/frontend/src/api/generated/site/site.ts @@ -26,11 +26,13 @@ import type { import type { CheckSlugParams, HTTPValidationError, + ListMySitesParams, ReqSiteSlug, ReqSiteStatus, ReqSiteTemplate, ReqSiteTheme, ReqStartBuild, + ResMySites, ResPublishLogs, ResSeoAudit, ResSite, @@ -467,7 +469,7 @@ export const useSetTemplate = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/site/list`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getListMySitesQueryKey = (params?: ListMySitesParams,) => { + return [ + `/v1/site/list`, ...(params ? [params]: []) + ] as const; + } + + +export const getListMySitesQueryOptions = >, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listMySites(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListMySitesQueryResult = NonNullable>> +export type ListMySitesQueryError = void | HTTPValidationError + + +export function useListMySites>, TError = void | HTTPValidationError>( + params: undefined | ListMySitesParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 내 사이트 목록 + */ + +export function useListMySites>, TError = void | HTTPValidationError>( + params?: ListMySitesParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListMySitesQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + From 282427e10b1e4f786bedaad8114576b17db864ac Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:43:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[feat]=20solution/frontend:=20=EB=82=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20=C2=B7=20=EB=82=B4=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9B=84?= =?UTF-8?q?=EC=97=90=20=EA=B0=88=20=EA=B3=B3=EC=9D=B4=20=EC=83=9D=EA=B2=BC?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(admin)으로 나갔고 사장님 앱에는 그 경로가 없다. 아임웹도 같은 자리를 계정 레벨(내사이트 · 마이페이지)로 두고, 사이트 레벨(관리자 페이지)과 가른다 — 우리는 그 사이트 레벨이 에디터다. - pages/SitesPage: 줄을 누르면 에디터로 간다(목록에 온 용건은 열에 아홉 "내 사이트 고치기"). [사이트 열기] 는 PUBLISHED 일 때만 — 주소는 발행 전에 예약돼서, 주소만 보고 열면 404 다. ⋯ 메뉴에는 [발행 내리기] 하나. ★ 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 다시 OTA 가 가져가고 되돌릴 방법이 사장님에게 없다(sites.status 주석) - pages/AccountPage: PATCH /v1/auth/me 가 받는 것만 그린다. 구글 계정은 비밀번호 칸을 접는다 (서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다). 상호는 읽기 전용 — Req_UpdateMe 에 없다 - router: `/` 가 로그인 여부로 갈린다. 복구(isRestoring) 전에는 판단하지 않는다 — 아니면 새로고침마다 위저드가 번쩍이고 목록으로 튄다 - AppShell: 메뉴에 [내 사이트], 계정 이름 자리가 [내 정보] 입구 검증 — tsc·eslint·vite build 통과(frontend·admin) --- solution/frontend/src/app/router.tsx | 47 +++- .../src/components/layout/AppShell.tsx | 25 +- solution/frontend/src/pages/AccountPage.tsx | 144 ++++++++++++ solution/frontend/src/pages/SitesPage.tsx | 213 ++++++++++++++++++ 4 files changed, 417 insertions(+), 12 deletions(-) create mode 100644 solution/frontend/src/pages/AccountPage.tsx create mode 100644 solution/frontend/src/pages/SitesPage.tsx diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index 4208797..ccd6b79 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -1,20 +1,63 @@ import {createBrowserRouter, Navigate} from 'react-router'; +import {Loader2} from 'lucide-react'; +import {AccountPage} from '@/pages/AccountPage'; import {BuilderPage} from '@/pages/BuilderPage'; import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {LoginPage} from '@/pages/LoginPage'; import {NotFoundPage} from '@/pages/NotFoundPage'; import {SignupPage} from '@/pages/SignupPage'; +import {SitesPage} from '@/pages/SitesPage'; +import {RequireAuth} from '@/components/layout/RequireAuth'; +import {useAuthStore} from '@/stores/auth'; + +/** + * 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라 + * "내 것 고치기"다. 비로그인은 그대로 위저드로 보낸다(관문은 에디터 진입이다). + * + * ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 위저드가 한 번 번쩍이고 목록으로 튄다. + */ +function Home() { + const isRestoring = useAuthStore((s) => s.isRestoring); + const user = useAuthStore((s) => s.user); + + if (isRestoring) { + return ( +
+ +
+ ); + } + return ; +} export const router = createBrowserRouter([ {path: '/login', element: }, // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. {path: '/signup', element: }, - // ★ 첫 화면은 업종 선택(위저드 1단계)이다. + // ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다. // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), // 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는 // 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다. - {path: '/', element: }, + {path: '/', element: }, + + // 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다. + { + path: '/sites', + element: ( + + + + ), + }, + { + path: '/account', + element: ( + + + + ), + }, /** * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index 61a1fe7..55f4384 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,6 @@ import type {ComponentType, ReactNode} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router'; -import {LayoutGrid, LogIn, LogOut, Search, Wand2} from 'lucide-react'; +import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react'; import {cn} from '@/lib/utils'; import {userLabel, useAuthStore} from '@/stores/auth'; @@ -23,6 +23,7 @@ export type NavItem = { * 남의 화면이다. */ const OWNER_NAV: NavItem[] = [ + {to: '/sites', match: '/sites', label: '내 사이트', icon: Store}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, ]; @@ -63,16 +64,20 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? **비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만 남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
-
- {user ? ( - <> - {userLabel(user)} - {user.companyName ? ` · ${user.companyName}` : ''} - - ) : ( + {/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */} + {user ? ( + + {userLabel(user)} + {user.companyName ? ` · ${user.companyName}` : ''} + + ) : ( +
로그인하지 않았습니다 - )} -
+
+ )} {user ? ( + + )} + + + ); +} + +function Field({label, children}: {label: string; children: React.ReactNode}) { + return ( + + ); +} diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx new file mode 100644 index 0000000..f6f10e7 --- /dev/null +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -0,0 +1,213 @@ +import {useState} from 'react'; +import {Link, useNavigate} from 'react-router'; +import { + Building2, + Coffee, + ExternalLink, + Loader2, + MoreHorizontal, + Pencil, + Plus, + Stethoscope, + UtensilsCrossed, + Wand2, +} from 'lucide-react'; +import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; +import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api'; +import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {notify, notifyApiError} from '@/lib/notify'; + +// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. +const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; + +const CATEGORY_ICON: Record = { + [PlaceCategory.LODGING]: Building2, + [PlaceCategory.CAFE]: Coffee, + [PlaceCategory.RESTAURANT]: UtensilsCrossed, + [PlaceCategory.CLINIC]: Stethoscope, +}; + +/** + * 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는 + * 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다. + */ +function statusBadge(row: MySiteData) { + if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const}; + switch (row.status) { + case SiteStatus.PUBLISHED: + return row.needs_rebuild + ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} + : {label: '발행됨', variant: 'success' as const}; + case SiteStatus.SUSPENDED: + return {label: '중지', variant: 'outline' as const}; + case SiteStatus.UNPUBLISHED: + return {label: '내림', variant: 'outline' as const}; + default: + return {label: '발행 전', variant: 'default' as const}; + } +} + +/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */ +function publishedUrl(row: MySiteData): string | null { + if (row.status !== SiteStatus.PUBLISHED || !row.domain) return null; + return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST); +} + +/** + * 내 사이트 — 로그인한 사장님의 홈이다. + * + * 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다. + * ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다. + */ +export function SitesPage() { + const navigate = useNavigate(); + const {data, isLoading, isError, error, refetch} = useListMySites({size: 50}); + const [busyId, setBusyId] = useState(null); + const [menuId, setMenuId] = useState(null); + + const rows = data?.sites ?? []; + + // 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면 + // 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석). + const handleUnpublish = async (row: MySiteData) => { + if (!window.confirm(`'${row.name}' 사이트를 검색에서 내릴까요?\n주소는 그대로 두고 페이지만 내려갑니다.`)) return; + setMenuId(null); + setBusyId(row.place_id); + try { + const res = await changeStatus(row.place_id, {action: PublishAction.UNPUBLISH}); + if (!res.result?.success) { + notifyApiError({data: res}, '사이트를 내리지 못했습니다.'); + return; + } + notify.success('사이트를 내렸습니다.'); + await refetch(); + } catch (unpublishError) { + notifyApiError(unpublishError, '사이트를 내리지 못했습니다.'); + } finally { + setBusyId(null); + } + }; + + return ( + + navigate('/builder?new=1')}> + 새 사이트 + + } + > + {isLoading && ( +
+ +
+ )} + + {isError && ( + refetch()}> + 다시 시도 + + } + /> + )} + + {!isLoading && !isError && rows.length === 0 && ( + navigate('/builder?new=1')}> + 첫 사이트 만들기 + + } + /> + )} + + {rows.length > 0 && ( +
    + {rows.map((row) => { + const Icon = CATEGORY_ICON[row.category] ?? Building2; + const badge = statusBadge(row); + const url = publishedUrl(row); + const editHref = `/builder?placeId=${row.place_id}`; + + return ( +
  • + + + +
    + {row.name} + {badge.label} +
    +

    + {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} +

    + + +
    + {url && ( + + + 사이트 열기 + + )} + + +
    + + {menuId === row.place_id && ( + <> + {/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */} + +
+ + )} + + ); + })} + + )} + + + ); +} From b07ade25b2c64e2fedc405f33bb7b8e665611070 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 2 Sep 2026 22:43:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20solution/frontend,docs:=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=EC=9C=84=EC=A0=80=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=E2=80=94=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EB=90=98=EA=B8=B0=20=EC=A0=84=EC=97=94=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EB=89=B4=EA=B0=80=20=EC=97=86=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 그걸 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 아임웹도 사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다. - BuilderPage: 위저드를 AppShell 대신 얇은 상단 바(로고 + 나가는 길)로. 진행은 WizardSteps 가 이미 보여준다. 비로그인은 돌아갈 목록이 없어 그 자리에 [로그인] 을 둔다 - BuilderPage: 에디터 헤더에 [← 내 사이트] — "내 사이트 관리가 생기면 그때 잇는다"고 비워 뒀던 자리다 - DEVLOG: 계정 레벨/사이트 레벨을 가른 근거 검증 — tsc·eslint·vite build 통과. 위저드에 사이드바가 사라진 것은 브라우저에서 확인 --- docs/DEVLOG.md | 31 +++++++++++++ solution/frontend/src/pages/BuilderPage.tsx | 49 ++++++++++++++++----- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index a550884..7672405 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,37 @@ --- +## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거 + +**왜** +로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로 +나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이 +`/builder?placeId=` 를 기억하는 것이었다. + +아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과 +**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로 +비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지 +않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다. + +**한 일** +- `GET /v1/site/list` — places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사업장 목록으로 + 그리면 줄마다 사이트를 다시 물어 N+1 이다. 사이트가 아직 없는 사업장도 내려간다 — + 빠지면 위저드를 걸어오다 만 가게를 다시 찾을 길이 없다. + `render`(정적 파일이 실제로 있는지)는 넣지 않았다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 + 파일 IO 가 된다. 단건(`Res_Site`)이 계속 소유한다. +- `/sites` 내 사이트 · `/account` 내 정보. `/` 는 로그인 여부로 갈린다(비로그인은 그대로 위저드). +- ⋯ 메뉴는 **[발행 내리기] 하나**다. 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 + 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다. +- 위저드에서 `AppShell`(사이드바)을 걷어내고 얇은 상단 바로 바꿨다. 사이드바는 계정 메뉴라, + 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 진행은 `WizardSteps` 가 + 이미 보여주므로 거기 필요한 건 로고와 나가는 길 하나다. +- 에디터 헤더에 [← 내 사이트]. `BuilderPage` 가 "내 사이트 관리가 생기면 그때 잇는다"고 + 비워 뒀던 자리다. + +**검증** — 백엔드 테스트 5건 추가(사이트 없는 사업장 · 조인 · 회사 격리 · 재빌드 판정이 단건과 +일치 · 비로그인 401), 539 passed. `tsc·eslint·vite build` 통과(frontend·admin). +위저드에 사이드바가 사라진 것은 브라우저에서 확인. + ## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절 **왜** diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 94c8551..621b04b 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -3,7 +3,6 @@ import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-re import {Link, useSearchParams} from 'react-router'; import {SiteStatus} from '@o2o/shared'; import {getAccessToken} from '@/api'; -import {AppShell} from '@/components/layout/AppShell'; import {EditorSignInGate} from '@/features/auth/EditorSignInGate'; import { Step1Industry, @@ -146,10 +145,14 @@ export function BuilderPage() { 실사업장 · {storeName} - {/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로 - 나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를 - 새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다. - 사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */} + {/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */} + + + 내 사이트 + ) : ( @@ -212,18 +215,44 @@ export function BuilderPage() { ); } - // 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다. - // 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다. + /** + * 위저드는 **사이드바를 쓰지 않는다.** + * + * ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를 + * 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. + * 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다. + */ return ( - -
+
+
+ Web4Ai + {/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */} + {isSignedIn ? ( + + + 내 사이트 + + ) : ( + + 로그인 + + )} +
+ +
{step === 1 && } {step === 2 && } {step === 3 && } {step === 4 && } {step === 5 && }
- +
); }