feat: /dev/clinics 페이지 + DevOnly 라우트 가드

GET /api/clinics 응답을 표로 보는 dev 전용 페이지 추가.
/dev/* 경로 전체를 DevOnly layout route 로 감싸 로컬호스트
(localhost/127.0.0.1/0.0.0.0/::1) 외 도메인에선 자동으로
루트로 리다이렉트.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
Mina Choi 2026-05-18 10:12:16 +09:00
parent e0610df826
commit 670535c112
3 changed files with 186 additions and 1 deletions

View File

@ -0,0 +1,28 @@
/**
* DevOnly `/dev/*` .
*
* `window.location.hostname` .
* "운영 도메인에서 실수로 노출되는 것 방지" .
* /CDN .
*/
import { Navigate, Outlet } from 'react-router';
const LOCAL_HOSTNAMES = new Set([
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1',
'[::1]',
]);
function isLocalHost(): boolean {
if (typeof window === 'undefined') return false;
return LOCAL_HOSTNAMES.has(window.location.hostname);
}
export default function DevOnly() {
if (!isLocalHost()) {
return <Navigate to="/" replace />;
}
return <Outlet />;
}

View File

@ -0,0 +1,148 @@
/**
* Dev: .
*
* `GET /api/clinics` (listClinics) .
* DevOnly .
*/
import { useState } from 'react';
import { useListClinics } from '@/shared/api/generated/clinics/clinics';
import { PageContainer } from '@/shared/ui/page-container';
import { Spinner } from '@/shared/ui/spinner';
import { EmptyState } from '@/shared/ui/empty-state';
import { Button } from '@/shared/ui/button';
const PAGE_SIZE = 50;
function formatDate(raw: string): string {
try {
return new Date(raw).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return raw;
}
}
export default function ClinicsPage() {
const [offset, setOffset] = useState(0);
const { data, isLoading, error, refetch, isFetching } = useListClinics(
{ limit: PAGE_SIZE, offset },
{ query: { staleTime: 0 } },
);
const items = data?.status === 200 ? data.data : [];
return (
<div className="pt-20 pb-16">
<PageContainer>
<header className="flex items-end justify-between mb-6 gap-4">
<div>
<p className="text-xs font-semibold text-[#6C5CE7] tracking-widest uppercase mb-2">
Dev · Clinics
</p>
<h1 className="font-serif text-3xl font-bold text-[#0A1128]">
</h1>
<p className="text-sm text-slate-500 mt-1">
GET /api/clinics · limit={PAGE_SIZE} · offset={offset}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? <Spinner size="xs" /> : null}
</Button>
</div>
</header>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" className="text-[#6C5CE7]" />
</div>
) : error ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
: {String((error as Error)?.message ?? error)}
</div>
) : items.length === 0 ? (
<EmptyState size="lg" hint="등록된 클리닉이 없습니다." />
) : (
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.04)]">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium">EN</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium">URL</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium font-mono text-xs">hospital_id</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{items.map((c) => (
<tr key={c.hospital_id} className="hover:bg-slate-50/50">
<td className="px-4 py-3 font-medium text-[#0A1128]">{c.hospital_name}</td>
<td className="px-4 py-3 text-slate-600">{c.hospital_name_en ?? '—'}</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-700">
{c.status}
</span>
</td>
<td className="px-4 py-3 text-slate-600 max-w-[240px] truncate">
{c.url ? (
<a
href={c.url}
target="_blank"
rel="noreferrer"
className="text-[#6C5CE7] hover:underline"
>
{c.url}
</a>
) : (
'—'
)}
</td>
<td className="px-4 py-3 text-slate-600 max-w-[260px] truncate">{c.road_address ?? '—'}</td>
<td className="px-4 py-3 text-slate-500 whitespace-nowrap">{formatDate(c.created_at)}</td>
<td className="px-4 py-3 font-mono text-xs text-slate-400">{c.hospital_id}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 페이지네이션 — 정확한 total 이 없어서 단순 prev/next 만 노출 */}
<div className="flex items-center justify-end gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
disabled={offset === 0 || isFetching}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setOffset((o) => o + PAGE_SIZE)}
disabled={items.length < PAGE_SIZE || isFetching}
>
</Button>
</div>
</PageContainer>
</div>
);
}

View File

@ -1,7 +1,16 @@
import { lazy } from 'react' import { lazy } from 'react'
import DevOnly from './components/DevOnly'
const ComponentsPage = lazy(() => import('./pages/ComponentsPage')) const ComponentsPage = lazy(() => import('./pages/ComponentsPage'))
const ClinicsPage = lazy(() => import('./pages/ClinicsPage'))
// `/dev/*` 는 DevOnly 가드를 거쳐 로컬호스트에서만 접근 가능.
export const devRoutes = [ export const devRoutes = [
{ path: 'dev/components', element: <ComponentsPage /> }, {
element: <DevOnly />,
children: [
{ path: 'dev/components', element: <ComponentsPage /> },
{ path: 'dev/clinics', element: <ClinicsPage /> },
],
},
] ]