[feat] solution/frontend: 내 사이트 · 내 정보 — 로그인 후에 갈 곳이 생겼다

로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(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)
This commit is contained in:
Mina Choi 2026-09-02 22:43:10 +09:00
parent 479edf9403
commit 282427e10b
4 changed files with 417 additions and 12 deletions

View File

@ -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 (
<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([
{path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다.
// ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
// 그냥 /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 {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?
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
<div className="border-t border-sidebar-border p-3">
<div className="mb-2 truncate text-[11px] text-sidebar-foreground">
{user ? (
<>
{userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</>
) : (
{/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
{user ? (
<Link
to="/account"
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>
)}
</div>
</div>
)}
{user ? (
<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

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