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 && ( + <> + {/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */} + +
+ + )} + + ); + })} + + )} + + + ); +}