Merge branch 'main' into feature/auth

This commit is contained in:
Mina Choi 2026-09-02 22:56:27 +09:00
commit a52d166fae
26 changed files with 960 additions and 32 deletions

View File

@ -5,6 +5,37 @@
--- ---
## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거
**왜**
로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로
나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이
`/builder?placeId=<uuid>` 를 기억하는 것이었다.
아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과
**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로
비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지
않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다.
**한 일**
- `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 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절 ## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절
**왜** **왜**

View File

@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG 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.enums import BuildStatus, ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime 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]: async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
pass pass
@abstractmethod
async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod @abstractmethod
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
pass pass
@ -85,6 +89,36 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None 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]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" """후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
try: try:

View File

@ -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.media.media.router)
app.include_router(router.v1.job.job.router) app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.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.router)
app.include_router(router.v1.local.local.weather_router) app.include_router(router.v1.local.local.weather_router)

View File

@ -4,8 +4,17 @@ from typing import Any, Optional
from pydantic import ConfigDict from pydantic import ConfigDict
from common.enums import BuildStatus, JobStatus, PublishAction, PublishRejectReason, PublishResult, SiteStatus from common.enums import (
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol BuildStatus,
JobStatus,
PlaceCategory,
PlaceStatus,
PublishAction,
PublishRejectReason,
PublishResult,
SiteStatus,
)
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class SiteProtocol(WebPacketProtocol): class SiteProtocol(WebPacketProtocol):
@ -56,6 +65,32 @@ class SiteData(WebPacketProtocol):
published_at: Optional[datetime] = None 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): class PublishLogData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -2,7 +2,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query 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 router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.site_service import SiteService from services.site_service import SiteService
from .protocol import ( from .protocol import (
@ -11,6 +11,7 @@ from .protocol import (
Req_SiteTemplate, Req_SiteTemplate,
Req_SiteTheme, Req_SiteTheme,
Req_StartBuild, Req_StartBuild,
Res_MySites,
Res_PublishLogs, Res_PublishLogs,
Res_Site, Res_Site,
Res_SeoAudit, Res_SeoAudit,
@ -23,6 +24,25 @@ from .protocol import (
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다. # 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}}) 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( @router.get(
path="", path="",

View File

@ -16,12 +16,13 @@ from common.enums import (
SiteStatus, SiteStatus,
) )
from common.logger import LOG 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 common.utils.gtime import GTime
from crud.job_crud import JobQueue from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.site_crud import ISiteCRUD, SiteCRUD from crud.site_crud import ISiteCRUD, SiteCRUD
from router.v1.site.protocol import ( from router.v1.site.protocol import (
MySiteData,
PublishLogData, PublishLogData,
RenderStatusData, RenderStatusData,
Req_SiteSlug, Req_SiteSlug,
@ -29,6 +30,7 @@ from router.v1.site.protocol import (
Req_SiteTemplate, Req_SiteTemplate,
Req_SiteTheme, Req_SiteTheme,
Req_StartBuild, Req_StartBuild,
Res_MySites,
Res_PublishLogs, Res_PublishLogs,
Res_Site, Res_Site,
Res_SeoAudit, Res_SeoAudit,
@ -417,6 +419,44 @@ class SiteService:
return _THEME_REJECTED return _THEME_REJECTED
return cleaned 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: async def get_site(self, user_info: UserInfo, place_id: str) -> Res_Site:
res = Res_Site() res = Res_Site()
err_type, place = await self._load_place(user_info, place_id) err_type, place = await self._load_place(user_info, place_id)

View File

@ -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

View File

@ -54,6 +54,7 @@ export * from './listFactsParams';
export * from './listFaqsParams'; export * from './listFaqsParams';
export * from './listLinksParams'; export * from './listLinksParams';
export * from './listMediaParams'; export * from './listMediaParams';
export * from './listMySitesParams';
export * from './listPlacesParams'; export * from './listPlacesParams';
export * from './localContentData'; export * from './localContentData';
export * from './localContentDataBody'; export * from './localContentDataBody';
@ -77,6 +78,14 @@ export * from './mediaDataUnitId';
export * from './mediaDataVisionConfidence'; export * from './mediaDataVisionConfidence';
export * from './mediaDataWidth'; export * from './mediaDataWidth';
export * from './mediaStatus'; 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 './placeCandidate';
export * from './placeCandidateAddress'; export * from './placeCandidateAddress';
export * from './placeCandidateCategoryName'; export * from './placeCandidateCategoryName';
@ -215,6 +224,8 @@ export * from './resMeMsg';
export * from './resMeName'; export * from './resMeName';
export * from './resMediaList'; export * from './resMediaList';
export * from './resMediaListMsg'; export * from './resMediaListMsg';
export * from './resMySites';
export * from './resMySitesMsg';
export * from './resPlace'; export * from './resPlace';
export * from './resPlaceList'; export * from './resPlaceList';
export * from './resPlaceListMsg'; export * from './resPlaceListMsg';

View File

@ -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;
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -16,9 +16,9 @@ import type { ReqSiteThemeTheme } from './reqSiteThemeTheme';
떨어뜨리므로 화면은 깨지지 않는다. 떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} {"theme": {"colors": {...}, "fontStyle": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. (별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다. 서버는 배달부지 심판이 아니다.

View File

@ -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[];
}

View File

@ -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;

View File

@ -26,11 +26,13 @@ import type {
import type { import type {
CheckSlugParams, CheckSlugParams,
HTTPValidationError, HTTPValidationError,
ListMySitesParams,
ReqSiteSlug, ReqSiteSlug,
ReqSiteStatus, ReqSiteStatus,
ReqSiteTemplate, ReqSiteTemplate,
ReqSiteTheme, ReqSiteTheme,
ReqStartBuild, ReqStartBuild,
ResMySites,
ResPublishLogs, ResPublishLogs,
ResSeoAudit, ResSeoAudit,
ResSite, ResSite,
@ -467,7 +469,7 @@ export const useSetTemplate = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/** /**
* 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: {"theme":{"colors":{...},"fontStyle":"...","colorPaletteId":"...","sections":[{"id","name","enabled","locked","variantId"}]}}. ★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). ★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. 직렬화 크기(64KB)만 막는다. ★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. ★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. ★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. ★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). 이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true). * 에디터가 정한 색·서체·섹션(순서·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 디자인(색·서체·섹션) 저장 * @summary 디자인(색·서체·섹션) 저장
*/ */
export const setTheme = ( export const setTheme = (
@ -849,4 +851,97 @@ export const useChangeStatus = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
} }
/**
* 로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.
* @summary 내 사이트 목록
*/
export const listMySites = (
params?: ListMySitesParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResMySites>(
{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 = <TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMySitesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMySites>>> = ({ signal }) => listMySites(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListMySitesQueryResult = NonNullable<Awaited<ReturnType<typeof listMySites>>>
export type ListMySitesQueryError = void | HTTPValidationError
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params: undefined | ListMySitesParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listMySites>>,
TError,
Awaited<ReturnType<typeof listMySites>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 내 사이트 목록
*/
export function useListMySites<TData = Awaited<ReturnType<typeof listMySites>>, TError = void | HTTPValidationError>(
params?: ListMySitesParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listMySites>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListMySitesQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -1,20 +1,63 @@
import {createBrowserRouter, Navigate} from 'react-router'; import {createBrowserRouter, Navigate} from 'react-router';
import {Loader2} from 'lucide-react';
import {AccountPage} from '@/pages/AccountPage';
import {BuilderPage} from '@/pages/BuilderPage'; import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LoginPage} from '@/pages/LoginPage'; import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage'; import {NotFoundPage} from '@/pages/NotFoundPage';
import {SignupPage} from '@/pages/SignupPage'; 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 (
<div className="flex h-screen items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
return <Navigate to={user ? '/sites' : '/builder?new=1'} replace />;
}
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />}, {path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />}, {path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다. // ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist), // `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
// 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는 // 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는
// 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다. // 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다.
{path: '/', element: <Navigate to="/builder?new=1" replace />}, {path: '/', element: <Home />},
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
{
path: '/sites',
element: (
<RequireAuth>
<SitesPage />
</RequireAuth>
),
},
{
path: '/account',
element: (
<RequireAuth>
<AccountPage />
</RequireAuth>
),
},
/** /**
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면

View File

@ -1,6 +1,6 @@
import type {ComponentType, ReactNode} from 'react'; import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router'; 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 {cn} from '@/lib/utils';
import {userLabel, useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
@ -23,6 +23,7 @@ export type NavItem = {
* 남의 화면이다. * 남의 화면이다.
*/ */
const OWNER_NAV: NavItem[] = [ const OWNER_NAV: NavItem[] = [
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
]; ];
@ -63,16 +64,20 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만 **비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */} 남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<div className="mb-2 truncate text-[11px] text-sidebar-foreground"> {/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
{user ? ( {user ? (
<> <Link
{userLabel(user)} to="/account"
{user.companyName ? ` · ${user.companyName}` : ''} className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
</> >
) : ( {userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</Link>
) : (
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
<span className="opacity-60">로그인하지 않았습니다</span> <span className="opacity-60">로그인하지 않았습니다</span>
)} </div>
</div> )}
{user ? ( {user ? (
<button <button
type="button" type="button"

View File

@ -0,0 +1,144 @@
import {useEffect, useState, type FormEvent} from 'react';
import {KeyRound, Loader2} from 'lucide-react';
import {AuthProvider, updateMe, useMe} from '@/api';
import {AppShell, PageContainer} from '@/components/layout/AppShell';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify';
import {toAuthUser, useAuthStore} from '@/stores/auth';
/**
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
*
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
* 입력칸 자체를 그리지 않는다.
*/
export function AccountPage() {
const {data, isLoading, refetch} = useMe();
const setUser = useAuthStore((s) => s.setUser);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [contact, setContact] = useState('');
const [password, setPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
// 서버 값이 도착하면 한 번 채운다. 타이핑 중에 덮어쓰지 않게 응답이 바뀔 때만 돈다.
useEffect(() => {
if (!data) return;
setName(data.name ?? '');
setEmail(data.email ?? '');
setContact(data.contact_number ?? '');
}, [data]);
const isGoogle = data?.provider === AuthProvider.GOOGLE;
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setIsSaving(true);
try {
const res = await updateMe({
name,
email,
contact_number: contact,
...(password ? {password} : {}),
});
if (!res.result?.success) {
notifyApiError({data: res}, '저장하지 못했습니다.');
return;
}
// 사이드바가 이름을 들고 있다 — 저장하고 스토어를 안 갱신하면 새로고침 전까지 옛 이름이다.
if (res.user_id && res.id) setUser(toAuthUser(res));
setPassword('');
notify.success('저장했습니다.');
await refetch();
} catch (error) {
notifyApiError(error, '저장하지 못했습니다.');
} finally {
setIsSaving(false);
}
};
return (
<AppShell>
<PageContainer title="내 정보" description="이름·연락처와 로그인 정보를 관리합니다.">
{isLoading ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : (
<form onSubmit={handleSubmit} className="max-w-lg space-y-5">
<section className="space-y-3 rounded-xl border border-border bg-card p-5">
<Field label="로그인 아이디">
<p className="text-sm">{isGoogle ? (data?.email ?? '구글 계정') : (data?.id ?? '')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
</p>
</Field>
<Field label="상호">
<p className="text-sm">{data?.company?.name ?? '-'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
상호 변경은 고객센터로 문의해 주세요.
</p>
</Field>
</section>
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<Field label="이름">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="홍길동" />
</Field>
<Field label="이메일">
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@example.com"
/>
</Field>
<Field label="연락처">
<Input value={contact} onChange={(e) => setContact(e.target.value)} placeholder="010-0000-0000" />
</Field>
</section>
{!isGoogle && (
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
<Field label="새 비밀번호">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비우면 지금 비밀번호를 그대로 씁니다"
autoComplete="new-password"
/>
</Field>
</section>
)}
{isGoogle && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<KeyRound className="size-3.5" />
구글 계정이라 비밀번호가 없습니다 — 비밀번호는 구글에서 관리합니다.
</p>
)}
<Button type="submit" variant="primary" isLoading={isSaving}>
저장
</Button>
</form>
)}
</PageContainer>
</AppShell>
);
}
function Field({label, children}: {label: string; children: React.ReactNode}) {
return (
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
}

View File

@ -3,7 +3,6 @@ import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-re
import {Link, useSearchParams} from 'react-router'; import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared'; import {SiteStatus} from '@o2o/shared';
import {getAccessToken} from '@/api'; import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate'; import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import { import {
Step1Industry, Step1Industry,
@ -146,10 +145,14 @@ export function BuilderPage() {
<span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success"> <span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success">
실사업장 · {storeName} 실사업장 · {storeName}
</span> </span>
{/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로 {/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */}
나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를 <Link
새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다. to="/sites"
사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */} className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<ArrowLeft className="size-3" />
<span>내 사이트</span>
</Link>
</> </>
) : ( ) : (
<span className="truncate opacity-80"> <span className="truncate opacity-80">
@ -212,18 +215,44 @@ export function BuilderPage() {
); );
} }
// 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다. /**
// 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다. * 위저드는 **사이드바를 쓰지 않는다.**
*
* ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를
* 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
* 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다.
*/
return ( return (
<AppShell> <div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<div className="flex h-full min-h-full flex-col"> <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
{/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */}
{isSignedIn ? (
<Link
to="/sites"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
내 사이트
</Link>
) : (
<Link
to="/login"
className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
로그인
</Link>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{step === 1 && <Step1Industry />} {step === 1 && <Step1Industry />}
{step === 2 && <Step2PlaceSearch />} {step === 2 && <Step2PlaceSearch />}
{step === 3 && <Step3DataReview />} {step === 3 && <Step3DataReview />}
{step === 4 && <Step4Template />} {step === 4 && <Step4Template />}
{step === 5 && <Step5Generating />} {step === 5 && <Step5Generating />}
</div> </div>
</AppShell> </div>
); );
} }

View File

@ -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<number, typeof Building2> = {
[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<string | null>(null);
const [menuId, setMenuId] = useState<string | null>(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 (
<AppShell>
<PageContainer
title="내 사이트"
description="만든 사이트를 열어 고치고, 다시 발행합니다."
actions={
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
<Plus />새 사이트
</Button>
}
>
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{isError && (
<EmptyState
title="목록을 불러오지 못했습니다"
description={(error as Error)?.message ?? '잠시 후 다시 시도해 주세요.'}
action={
<Button size="sm" onClick={() => refetch()}>
다시 시도
</Button>
}
/>
)}
{!isLoading && !isError && rows.length === 0 && (
<EmptyState
icon={Wand2}
title="아직 만든 사이트가 없습니다"
description="상호명 하나로 시작하면 AI 가 정보를 모아 사이트를 만듭니다."
action={
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
<Plus />첫 사이트 만들기
</Button>
}
/>
)}
{rows.length > 0 && (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
{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 (
<li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<Link to={editHref} className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{row.name}</span>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p>
</Link>
<div className="flex shrink-0 items-center gap-1.5">
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
>
<ExternalLink className="size-3.5" />
사이트 열기
</a>
)}
<Button size="sm" onClick={() => navigate(editHref)}>
<Pencil />
{row.site_id ? '편집' : '이어서 만들기'}
</Button>
<Button
size="icon"
variant="ghost"
aria-label="더보기"
isLoading={busyId === row.place_id}
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
>
{busyId === row.place_id ? null : <MoreHorizontal />}
</Button>
</div>
{menuId === row.place_id && (
<>
{/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */}
<button
type="button"
aria-label="닫기"
className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)}
/>
<div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
<button
type="button"
disabled={row.status !== SiteStatus.PUBLISHED}
onClick={() => handleUnpublish(row)}
className="w-full cursor-pointer px-3 py-2 text-left text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
>
발행 내리기
</button>
</div>
</>
)}
</li>
);
})}
</ul>
)}
</PageContainer>
</AppShell>
);
}