o2o-site-AEO/solution/frontend/src/pages/SitesPage.tsx
hbyang 0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00

463 lines
21 KiB
TypeScript

import {SocialConnectionNotice} from '@/features/social/SocialConnectionNotice';
import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router';
import {
Building2,
Coffee,
ExternalLink,
Loader2,
MoreHorizontal,
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 />
{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-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>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default SitesPage;