o2o-site-AEO/solution/frontend/src/pages/SitesPage.tsx
hbyang b1a34ba58d [feat] solution/backend,frontend: 에이전트 도구·런타임·빌더 채팅창 — 2단계
런타임이 채널을 모르므로 채널·챗봇 심사 없이 에이전트 전체를 빌더 화면에서
검증할 수 있다. 웹훅 핸들러 안에 짜면 빌더에서 같은 걸 못 쓰고, 심사가 끝나야
무엇 하나 확인되지 않는다 — 카톡은 나중에 붙는 두 번째 입구다.

- services/agent/tools.py: 도구 넷 + 등급 셋(READ·REVERSIBLE·SEMI).
  ★ 도구는 반드시 services/* 를 통과한다 — crud 를 직접 부르면 스키마 검증·
  출처 필수·정정본 보호가 아무 증상 없이 사라진다. 테스트가 소스로 검사한다
- services/agent/runtime.py: 발화 → 도구 선택(LLM 1콜) → 실행 → 응답
- services/prompts/agent.py: LLM 네 겹 규약대로 프롬프트만 여기
- router/v1/agent/chat.py + features/agent/AgentChatDock.tsx(/sites 우하단)

모델에게 맡기지 않은 셋:
- 등급 — 응답 스키마에 칸 자체가 없다. 모델이 정하면 프롬프트에 끼어든 한 줄이
  확인 절차를 건너뛴다
- 결과 문구 — 도구가 만든다. 모델이 쓰면 하지 않은 일을 했다고 말할 수 있고
  사장님에게는 그 말이 사실로 보인다
- key — set_fact 의 key 는 업종 스키마가 최종 판정이다

확인(SEMI)은 실행하지 않고 되묻는다. 돌아온 confirm 값을 믿지 않고 도구는
레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다 — 확인 절차가 검증을
건너뛰는 구멍이 되면 안 된다.

값을 고치면 재발행 안내를 함께 낸다 — fact 는 바뀌어도 사이트는 안 바뀐다.

test_agent_runtime.py 17 passed(LLM 은 monkeypatch, 실제 모델 호출 없음).
전체 796 passed / 50 failed — 그 50건은 HEAD 에서도 동일한 기존 이슈.
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:18:00 +09:00

516 lines
24 KiB
TypeScript

import {SocialConnectionCard} from '@/features/social/SocialConnectionCard';
import {KakaoChannelCard} from '@/features/agent/KakaoChannelCard';
import {AgentChatDock} from '@/features/agent/AgentChatDock';
import {SocialConnectionNotice} from '@/features/social/SocialConnectionNotice';
import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router';
import {
Building2,
CalendarClock,
Coffee,
ExternalLink,
Loader2,
MoreHorizontal,
Newspaper,
Pencil,
Plus,
Search,
SearchX,
Stethoscope,
UtensilsCrossed,
Wand2,
X,
} 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 {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify';
import {PUBLISH_HOST} from '@/lib/site';
import {cn} from '@/lib/utils';
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.LODGING]: Building2,
[PlaceCategory.CAFE]: Coffee,
[PlaceCategory.RESTAURANT]: UtensilsCrossed,
[PlaceCategory.CLINIC]: Stethoscope,
};
/**
* 줄이 속하는 칸. **배지·필터·정렬이 전부 이 하나로 판정한다.**
*
* ★ 판정을 갈라 쓰면 조용히 틀린다 — '발행됨(1)' 을 눌렀는데 '내림' 배지가 낀 줄이 같이
* 나오는 종류다. 건수까지 틀리므로 사장님은 목록을 못 믿게 된다.
* ★ 세 칸이 목록을 빈틈없이 나눈다. 하나라도 빠지면 '전체' 건수와 칸 건수의 합이 어긋난다.
*/
type SiteBucket = 'live' | 'draft';
type SiteFilter = SiteBucket | 'all';
/**
* ★ 칸은 **둘**이다. 예전엔 '만드는 중'(사이트 행 없음)을 따로 뒀는데, 사장님에게 그 둘은
* 같은 상태다 — "아직 안 나가 있다". `site_id` 가 있고 없고는 우리 DB 사정이지 사장님의
* 구분이 아니고, 칸이 셋이면 34개가 어디 있는지 두 번 세게 된다.
*/
const FILTER_LABEL: Record<SiteFilter, string> = {
all: '전체',
live: '발행됨',
draft: '발행 전',
};
const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft'];
/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */
const BUCKET_ORDER: Record<SiteBucket, number> = {live: 0, draft: 1};
function bucketOf(row: MySiteData): SiteBucket {
return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft';
}
/**
* 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는
* 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다.
*/
function statusBadge(row: MySiteData) {
if (bucketOf(row) === 'live') {
return row.needs_rebuild
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
: {label: '발행됨', variant: 'success' as const};
}
// 같은 '발행 전' 이어도 **한 번 나갔다가 내린 것**은 말해 준다 — 되돌리는 일과 처음 내는 일은
// 사장님이 할 행동이 다르다. 그 외(초안·수집 중)는 전부 '발행 전' 한 마디다.
if (row.status === SiteStatus.SUSPENDED) return {label: '중지', variant: 'outline' as const};
if (row.status === SiteStatus.UNPUBLISHED) return {label: '내림', variant: 'outline' as const};
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);
}
/**
* 줄 뒤에 붙는 시각. **발행됐으면 발행일, 아니면 만든 날**이다.
*
* ★ 두 개를 같이 걸지 않는다. Wix·아임웹 목록이 시각을 한 칸만 쓰는 이유와 같다 —
* 목록에서 궁금한 건 "이게 언제 나갔나" 하나이고, 아직 안 나간 줄에만 만든 날이 의미가 있다.
* ★ 연도는 올해면 뗀다. 줄이 좁아 주소가 먼저 잘린다.
*/
function whenLabel(row: MySiteData): string {
const raw = row.published_at ?? row.created_at;
if (!raw) return '';
const at = new Date(raw);
if (Number.isNaN(at.getTime())) return '';
const now = new Date();
const date = at.toLocaleDateString('ko-KR', {
...(at.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}),
month: 'long',
day: 'numeric',
});
return row.published_at ? `${date} 발행` : `${date} 만듦`;
}
/** 정렬용 시각. whenLabel 이 고른 값과 같은 것을 쓴다 — 화면에 보이는 날짜와 순서가 갈라지면 안 된다. */
function rowTime(row: MySiteData): number {
const raw = row.published_at ?? row.created_at;
if (!raw) return 0;
const at = new Date(raw).getTime();
return Number.isNaN(at) ? 0 : at;
}
/**
* 검색 비교용 정규화. **공백을 지운다** — 실측(계정 test)에 같은 상호 '버터브루' 가 4줄인데
* 사장님은 '버터 브루' 로도 친다. 한국어 상호는 띄어쓰기가 원본마다 다르다.
*/
function normalizeText(value: string): string {
return value.toLowerCase().replace(/\s+/g, '');
}
/** 상호와 도로명 주소만 본다 — 상호가 겹치는 줄을 실제로 가르는 건 주소다. */
function matchesQuery(row: MySiteData, needle: string): boolean {
if (!needle) return true;
return normalizeText(row.name).includes(needle) || normalizeText(row.road_address ?? '').includes(needle);
}
/**
* 줄 앞의 그림. 발행에 성공한 사이트만 썸네일이 있고(sites.thumbnail_url),
* 없으면 업종 아이콘으로 떨어진다.
*
* ★ 주소에 `?v=<버전>` 이 붙어 있다 — 발행할 때마다 바뀐다(site_thumbnail.public_url).
* 그래서 <img> 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은
* 재방문에도 매번 새로 받는다.
* ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이
* 뜨는 것보다 낫다.
*/
function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) {
const [failed, setFailed] = useState(false);
const src = row.thumbnail_url;
// ★ 비율은 **16:10 — 브라우저 창 비율**이다. 이 그림은 사이트 미리보기라 1:1 로 자르면
// 위아래가 잘려 무슨 사이트인지 알아볼 수 없다(사진첩이 아니다).
// ★ 크기는 아임웹 내사이트 화면을 보고 키웠다(2026-09-08). 거기 썸네일도 줄 높이의 대부분을
// 차지한다 — 같은 상호가 여러 줄일 때 그림이 유일한 구분자인데 작으면 있으나 마나다.
if (!src || failed) {
return (
<div className="flex aspect-[16/10] w-full items-center justify-center border-b border-border bg-muted/60">
<Icon className="size-7 text-muted-foreground/60" />
</div>
);
}
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="aspect-[16/10] w-full border-b border-border object-cover"
/>
);
}
/**
* 내 사이트 — 로그인한 사장님의 홈이다.
*
* 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다.
* ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다.
*
* ★ 목록이 하는 일은 셋뿐이다 — ① 어느 게 어느 건지 가르고 ② 지금 상태를 말하고 ③ 여는 길을 준다.
* 방문자·주문 같은 숫자는 여기 넣지 않는다(Wix·아임웹도 사이트 안 대시보드에 둔다).
*/
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 [query, setQuery] = useState('');
const [filter, setFilter] = useState<SiteFilter>('all');
const rows = useMemo(() => data?.sites ?? [], [data]);
// ★ 서버로 안 보낸다. 한 번에 50줄을 이미 다 받아 놓았고(실측 35줄), 서버 검색을 붙이면
// 글자마다 왕복 + 디바운스 + 늦게 온 응답이 최신 결과를 덮는 경합까지 따라온다.
// → 50줄을 넘어 페이지가 생기는 날이 오면 그때는 서버가 맞다(이 필터는 첫 페이지만 본다).
const found = useMemo(() => {
const needle = normalizeText(query);
return rows.filter((row) => matchesQuery(row, needle));
}, [rows, query]);
// ★ 건수는 **검색 결과 위에서** 센다. 검색어를 친 뒤 "발행됨 0 · 만드는 중 3" 이 보여야
// 찾는 게 어느 칸에 있는지 알 수 있다. 검색 전 건수를 그대로 두면 칸을 눌러 보고서야 안다.
const counts = useMemo(() => {
const tally: Record<SiteFilter, number> = {all: found.length, live: 0, draft: 0};
for (const row of found) tally[bucketOf(row)] += 1;
return tally;
}, [found]);
// 서버는 사업장 생성 역순으로만 준다(site_crud.list_owner_sites) — 발행 여부를 모른다.
// 나가 있는 것을 위로 올리는 건 여기서 한다.
const visible = useMemo(() => {
const picked = filter === 'all' ? found : found.filter((row) => bucketOf(row) === filter);
return [...picked].sort(
(a, b) => BUCKET_ORDER[bucketOf(a)] - BUCKET_ORDER[bucketOf(b)] || rowTime(b) - rowTime(a),
);
}, [found, filter]);
const isNarrowed = query.trim().length > 0 || filter !== 'all';
const resetView = () => {
setQuery('');
setFilter('all');
};
// 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 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>
}
>
<SocialConnectionNotice />
{/* 연결은 사업장이 아니라 사람 단위다 — 목록 위에 한 자리만 둔다(SocialConnectionCard 주석). */}
<SocialConnectionCard />
{/* 카카오톡 연결도 사람 단위다 — 같은 자리에 나란히 둔다(KakaoChannelCard 주석). */}
<KakaoChannelCard />
{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>
}
/>
)}
{!isLoading && !isError && rows.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="상호나 주소로 찾기"
aria-label="상호나 주소로 찾기"
className="h-9 pr-8 pl-8"
/>
{query && (
<button
type="button"
aria-label="검색어 지우기"
onClick={() => setQuery('')}
className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */}
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
{FILTER_TABS.map((key) => (
<button
key={key}
type="button"
aria-pressed={filter === key}
onClick={() => setFilter(key)}
className={cn(
'h-8 cursor-pointer rounded px-2.5 text-xs font-medium transition-colors',
filter === key
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{FILTER_LABEL[key]}
<span className="ml-1 tabular-nums opacity-60">{counts[key]}</span>
</button>
))}
</div>
</div>
)}
{/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면
사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */}
{!isLoading && !isError && rows.length > 0 && visible.length === 0 && (
<EmptyState
icon={SearchX}
title="조건에 맞는 사이트가 없습니다"
description={`가진 사이트 ${rows.length}개 중에 없습니다. 검색어를 줄이거나 다른 칸을 보세요.`}
action={
<Button size="sm" onClick={resetView}>
조건 지우기
</Button>
}
/>
)}
{visible.length > 0 && (
/* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은
줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라
(실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다.
비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((row) => {
const Icon = CATEGORY_ICON[row.category] ?? Building2;
const badge = statusBadge(row);
const url = publishedUrl(row);
const isLive = bucketOf(row) === 'live';
const editHref = `/builder?placeId=${row.place_id}`;
return (
/* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일'
줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적).
h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */
<li
key={row.place_id}
className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md"
>
<Link to={editHref} className="block">
<SiteThumb row={row} Icon={Icon} />
</Link>
{/* 배지는 그림 위에 얹는다 — 카드에서 상태는 제목보다 먼저 읽혀야 한다. */}
<Badge variant={badge.variant} className="absolute top-2.5 left-2.5 shadow-sm">
{badge.label}
</Badge>
<div className="flex flex-1 flex-col p-3.5">
<Link to={editHref} className="block min-w-0">
<p className="truncate text-sm font-semibold">{row.name}</p>
{/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라
같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */}
<p
className={cn(
'mt-1 truncate text-xs',
isLive ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p>
{/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */}
<p className="mt-1 mb-3 truncate text-[11px] text-muted-foreground/70">
{[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')}
</p>
</Link>
<div className="mt-auto flex items-center gap-1.5 border-t border-border pt-3">
{/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이
같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */}
<Button size="sm" className="flex-1" onClick={() => navigate(editHref)}>
<Pencil />
편집
</Button>
{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="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>
</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-3.5 bottom-12 z-20 w-52 rounded-md border border-border bg-card py-1 shadow-md">
<button
type="button"
onClick={() => {
setMenuId(null);
navigate(editHref);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<Pencil className="size-3.5" />
디자인·컨텐츠 관리
</button>
<button
type="button"
onClick={() => {
setMenuId(null);
navigate(`/blog?placeId=${row.place_id}`);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<Newspaper className="size-3.5" />
미니블로그 관리
</button>
<button
type="button"
onClick={() => {
setMenuId(null);
// ★ 예약 요청은 DB 에 안 남는다(2026-09-16 대표 지시, booking_request.py) —
// 메일로만 가고 우리 쪽엔 로그 한 줄만 남는다. 목록 화면을 만들려면
// 그 결정부터 뒤집어야 한다 — 지금은 안내만 한다.
notify.info(
'예약 요청은 메일로만 전달됩니다',
'연락처를 따로 저장하지 않기로 했습니다(2026-09-16). 목록이 필요하면 먼저 이 방침을 바꿔야 합니다.',
);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<CalendarClock className="size-3.5" />
예약요청 관리
</button>
<div className="my-1 border-t border-border" />
<button
type="button"
disabled={row.status !== SiteStatus.PUBLISHED}
onClick={() => handleUnpublish(row)}
className="w-full cursor-pointer px-3 py-2 text-left text-xs text-destructive transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
>
발행 내리기
</button>
</div>
</>
)}
</li>
);
})}
</ul>
)}
</PageContainer>
{/* 떠 있는 대화창. 목록 위가 아니라 화면 구석인 이유는, 이게 '또 하나의 카드' 가 아니라
어느 화면에서든 부를 수 있는 창구이기 때문이다(AgentChatDock 주석). */}
<AgentChatDock sites={rows.map((row) => ({place_id: String(row.place_id), name: row.name}))} />
</AppShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default SitesPage;