o2o-site-AEO/solution/frontend/src/components/layout/AppShell.tsx
Mina Choi 7a6fee70c1 [fix] solution/frontend: 로그인해도 랜딩·요금에 갈 길을 낸다 — '/' 관문 제거
로그인하면 '/' 가 무조건 /sites 로 튕겼다. 그래서 **로고를 눌러도 랜딩이 안 뜨고**,
/pricing 은 살아 있는데 링크가 랜딩과 MarketingShell 에만 있어 로그인한 사장님은
주소를 직접 쳐야 했다. 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다.

- app/router.tsx: Home 의 리다이렉트 제거. '/' 는 누구에게나 랜딩이다.
  "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다.
- pages/LoginPage.tsx: 도착지를 `homePath` prop 으로 받는다. 기본값 '/' 라
  내부 운영 앱(자기 '/' 가 사업장 목록으로 간다)은 그대로다. 사장님 앱만 '/sites'.
- pages/SignupPage.tsx: 가입 후 '/' → '/sites'. 안 그러면 관문이 없어진 지금 랜딩에 떨어진다.
- 로그인·가입 화면 로고에 '/' 링크. 그 화면에서 빠져나갈 길이 하나도 없었다.
- layout/AppShell.tsx: 로그아웃 → '/login' 이 아니라 '/'. 나간 사람에게 로그인 폼을
  다시 들이밀지 않는다. 사이드바에 [요금] 추가.
- layout/MarketingShell.tsx: 헤더 [무료로 만들기] 제거 — 히어로 입력 카드가 이미 그 자리다.
  시작하는 문이 한 화면에 둘이면 어느 쪽이 진짜인지 고르게 만든다.

검증: tsc·eslint 통과. docker compose up -d --build solution-site 로 띄워 눌러 확인 —
로그인 상태에서 로고 → 랜딩, 헤더는 [이렇게 나옵니다 · 요금 · 내 사이트].
2026-09-04 17:29:13 +09:00

171 lines
7.4 KiB
TypeScript

import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router';
import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react';
import {cn} from '@/lib/utils';
import {userLabel, useAuthStore} from '@/stores/auth';
export type NavItem = {
to: string;
/** 활성 표시용 경로. 쿼리스트링이 붙은 `to` 로는 pathname 을 비교할 수 없다. */
match: string;
label: string;
icon: ComponentType<{className?: string}>;
};
/**
* ★ 메뉴를 이 파일에 하드코딩하지 않는다 — 앱마다 다르고, **섞이면 새어 나간다.**
* 내부 메뉴(`/places`, `/local-content`)를 여기 두면 그 경로 이름이 사장님 번들에
* 문자열로 남는다(실측: 앱을 가른 뒤에도 dist 에서 `local-content` 가 나왔다).
* 앱을 가른 이유가 그거라 메뉴도 앱이 들고 온다. 내부 메뉴는 `admin/src/app/router.tsx`.
*
* ★ 빌더는 `?new=1` 로 간다. 그냥 `/builder` 로 보내면 저장된 위저드 상태가 복원돼
* 지난번에 편집하던 가게의 에디터가 뜬다(BuilderPage 주석) — 새로 만들러 누른 사람에게는
* 남의 화면이다.
*/
const OWNER_NAV: NavItem[] = [
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
/*
* ★ 요금은 **로그인한 뒤에도** 갈 길이 있어야 한다 (2026-09-04, 사장님 지적)
* /pricing 은 살아 있는데 링크가 MarketingShell 과 랜딩에만 있었다. 둘 다 로그인하면
* 안 보이는 화면이라(로그인하면 / 가 /sites 로 간다), 사장님은 주소를 직접 쳐야 했다.
* 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다.
* ★ 목적지가 MarketingShell(사이드바 없는 문서형)이라 사이드바가 사라진다. 그래도 갇히지
* 않는다 — 저쪽 헤더가 로그인 상태를 알아보고 [내 사이트] 버튼을 세운다.
*/
{to: '/pricing', match: '/pricing', label: '요금', icon: Receipt},
];
export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) {
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
const location = useLocation();
const navigate = useNavigate();
return (
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
<aside className="hidden w-56 shrink-0 flex-col border-r border-sidebar-border bg-sidebar md:flex">
{/* ★ 로고는 언제나 홈(/)이다. 메뉴 첫 항목으로 보내면 앱마다 목적지가 달라지고,
로고를 눌러 첫 화면으로 가려던 사람이 엉뚱한 목록에 떨어진다. */}
<Link to="/" className="flex items-center gap-2 px-4 py-4">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-7 w-auto" />
</Link>
<nav className="flex-1 space-y-0.5 px-2">
{nav.map(({to, match, label, icon: Icon}) => (
<NavLink
key={to}
to={to}
className={() =>
cn(
'flex items-center gap-2 rounded-md px-2.5 py-2 text-xs font-medium transition-colors',
location.pathname.startsWith(match)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/60',
)
}
>
<Icon className="size-4" />
<span>{label}</span>
</NavLink>
))}
</nav>
{/* ★ 위저드는 로그인 없이도 열린다(관문은 에디터 진입이다) — 그래서 이 자리는
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
<div className="border-t border-sidebar-border p-3">
{/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
{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>
)}
{user ? (
<button
type="button"
// 로그아웃은 **눈에 보이는 결과**가 있어야 한다. 스토어만 비우면 화면은 그대로라
// 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다.
onClick={() => {
signOut();
// ★ 로그인 화면이 아니라 랜딩으로. 나간 사람에게 다시 로그인 폼을 들이밀지 않는다.
navigate('/');
}}
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogOut className="size-3.5" />
<span>로그아웃</span>
</button>
) : (
<Link
to="/login"
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
<LogIn className="size-3.5" />
<span>로그인</span>
</Link>
)}
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</div>
);
}
export function PageContainer({
title,
description,
actions,
children,
}: {
title: string;
description?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="mx-auto w-full max-w-6xl px-5 py-6">
<header className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">{title}</h1>
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</header>
{children}
</div>
);
}
export function EmptyState({
icon: Icon = LayoutGrid,
title,
description,
action,
}: {
icon?: typeof Search;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border px-6 py-14 text-center">
<Icon className="size-6 text-muted-foreground" />
<p className="text-sm font-medium">{title}</p>
{description && <p className="max-w-md text-xs text-muted-foreground">{description}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
);
}