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 = { [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 = { all: '전체', live: '발행됨', draft: '발행 전', }; const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft']; /** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */ const BUCKET_ORDER: Record = {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). * 그래서 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은 * 재방문에도 매번 새로 받는다. * ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이 * 뜨는 것보다 낫다. */ 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 (
); } return ( 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(null); const [menuId, setMenuId] = useState(null); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('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 = {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 ( navigate('/builder?new=1')}> 새 사이트 } > {isLoading && (
)} {isError && ( refetch()}> 다시 시도 } /> )} {!isLoading && !isError && rows.length === 0 && ( navigate('/builder?new=1')}> 첫 사이트 만들기 } /> )} {!isLoading && !isError && rows.length > 0 && (
setQuery(event.target.value)} placeholder="상호나 주소로 찾기" aria-label="상호나 주소로 찾기" className="h-9 pr-8 pl-8" /> {query && ( )}
{/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */}
{FILTER_TABS.map((key) => ( ))}
)} {/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면 사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */} {!isLoading && !isError && rows.length > 0 && visible.length === 0 && ( 조건 지우기 } /> )} {visible.length > 0 && ( /* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은 줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라 (실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다. 비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */
    {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 로 액션 줄을 항상 카드 바닥에 붙인다. */
  • {/* 배지는 그림 위에 얹는다 — 카드에서 상태는 제목보다 먼저 읽혀야 한다. */} {badge.label}

    {row.name}

    {/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라 같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */}

    {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}

    {/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */}

    {[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')}

    {/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이 같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */} {url && ( 열기 )}
    {menuId === row.place_id && ( <> {/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */} )}
  • ); })}
)}
); } // 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). export default SitesPage;