o2o-site-AEO/solution/frontend/src/features/publish/PublishModal.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

609 lines
26 KiB
TypeScript

import {SocialPanel} from '@/features/social/SocialPanel';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useNavigate} from 'react-router';
import {
AlertTriangle,
ArrowUpRight,
Check,
Copy,
ExternalLink,
Eye,
Loader2,
RefreshCw,
Search,
ServerCrash,
ShieldAlert,
Store,
} from 'lucide-react';
import {publishUrlString, toSlug} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Dialog} from '@/components/ui/dialog';
import {SignInForm} from '@/features/auth/SignInForm';
import type {WizardStep} from '@/features/onboarding/wizardUrl';
import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder';
import {runPublishGate, type GateFinding} from './publishGate';
import {checkSiteSlug, reserveSiteSlug} from './siteSlug';
import {PUBLISH_HOST} from '@/lib/site';
import {localValidate, SlugField, type SlugStatus} from './SlugField';
import {
GATE_REASON_LABEL,
usePublishSite,
type BuildGateResult,
type PublishState,
} from './usePublishSite';
// 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트
// 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다.
// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
/**
* 가게 확인이 안 끝난 사장님을 돌려보낼 곳.
*
* ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날
* 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep).
*/
const PLACE_SEARCH_STEP: WizardStep = 'search';
const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`;
/**
* 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다.
*
* ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고
* 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라
* 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다.
* ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의
* [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데
* 발행만 안 된다.
*/
type PublishBlocker = 'signin' | 'place';
export function PublishModal() {
const isOpen = useBuilderStore((s) => s.isPublishModalOpen);
const close = useBuilderStore((s) => s.closePublishModal);
const storeName = useBuilderStore((s) => s.storeName);
const location = useBuilderStore((s) => s.location);
const sections = useBuilderStore((s) => s.sections);
const infoFields = useBuilderStore((s) => s.infoFields);
const photos = useBuilderStore((s) => s.photos);
const publishedUrl = useBuilderStore((s) => s.publishedUrl);
const setPublishedUrl = useBuilderStore((s) => s.setPublishedUrl);
const placeId = useBuilderStore((s) => s.placeId);
const [copied, setCopied] = useState(false);
/**
* 발행 주소. 서버가 이미 확정했으면 그걸 보여주고 잠근다 — 색인된 주소는 바꾸지 않는다.
* 아직이면 사장님이 고른다(상호에서 자동 생성하지 않는다 — SlugField 주석 참고).
*/
const [slug, setSlug] = useState('');
const [slugStatus, setSlugStatus] = useState<SlugStatus>({kind: 'idle'});
/**
* 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링).
* ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다.
* PublishBlocker 주석 참고.
*/
const publisher = usePublishSite(placeId);
const {state, reset} = publisher;
const navigate = useNavigate();
/*
* ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도
* 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 —
* 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다.
*/
useAuthStore((s) => s.user);
/** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */
const blocker: PublishBlocker | null = publisher.isLive
? null
: getAccessToken()
? 'place'
: 'signin';
const gate = useMemo(
() =>
runPublishGate({
storeName,
location,
infoFields,
photos,
sections,
// ★ 게이트의 '고유 콘텐츠'는 수집·생성된 소개문이어야 한다.
// 예전에는 업종 시드의 introText(가공의 문장)를 넣어서, 실제로는 이 가게만의
// 콘텐츠가 한 글자도 없어도 게이트가 통과했다 — 스팸 판정을 막으라고 둔 검사가
// 시연용 문장으로 자기 자신을 속이고 있었다.
uniqueContent: [
infoFields.find((f) => f.id === 'intro' || f.id === 'room_intro')?.value ?? '',
].filter((s) => s.trim()),
}),
[storeName, location, infoFields, photos, sections],
);
// 한글 상호는 서브도메인에 못 넣는다(퓨니코드 문제) — publishUrlString 이 경로형으로 떨어뜨린다.
// 서버가 도메인을 확정해 줬으면(sites.domain) 그게 우선이다.
const domain = publisher.site?.domain;
/** 주소가 이미 서버에 박혀 있는가 — 그러면 입력을 잠근다. */
const isSlugLocked = Boolean(domain);
const url =
(domain ? publishUrlString(domain.split('.')[0], PUBLISH_HOST) : null) ??
publishedUrl ??
(slug ? publishUrlString(slug, PUBLISH_HOST) : '');
// 서버가 준 주소가 있으면 입력칸을 그 값으로 맞춰 둔다(사장님이 뭘 쓰는지 보이게).
useEffect(() => {
if (domain) setSlug(domain.split('.')[0]);
}, [domain]);
/** 주소가 정해지기 전에는 발행 버튼을 열지 않는다 — 주소 없이 발행하면 되돌릴 수 없다. */
const slugReady = isSlugLocked || (!localValidate(slug) && slugStatus.kind === 'available');
// 서버가 발행을 확정했을 때만 주소를 스토어에 박는다 — 게이트에 막힌 주소를 '발행됨'으로 남기지 않는다.
useEffect(() => {
if (state.phase === 'published') setPublishedUrl(url);
}, [state.phase, url, setPublishedUrl]);
// 모달을 닫으면 지난 빌드 결과를 버린다. 다시 열었을 때 옛 거부 사유가 남아 있으면
// 이미 고친 항목을 다시 고치라고 말하는 꼴이 된다.
useEffect(() => {
if (!isOpen) reset();
}, [isOpen, reset]);
/**
* ★ 서버가 발행을 확정한 것만 '발행됨'이다.
* 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이
* 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다.
*/
const isDone = state.phase === 'published';
/**
* 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다.
*
* ★ 발행 = 빌드다. 재발행을 누르면 HTML 이 처음부터 다시 구워지고 새 버전이 남는다
* (usePublishSite 주석). 사장님에게는 그게 "고친 걸 사이트에 반영한다"는 뜻이다.
* ★ 발행이 끝난 뒤(isDone)에는 이 값이 참으로 뒤집히지만, 그때 화면은 완료 분기라
* 여기 문구를 쓰지 않는다 — 버튼 글자가 도중에 바뀌는 일은 없다.
*/
const isRepublish = Boolean(publisher.currentVersion?.version);
/**
* 주소 중복 확인.
*
* ★ 서버 판정이 유일한 근거다. 화면 정규식은 왕복을 줄이는 사전 점검일 뿐이고,
* "사용 가능"은 서버가 말해야 한다 — 두 사람이 동시에 같은 주소를 노릴 수 있다.
*/
const checkSlug = useCallback(async () => {
const local = localValidate(slug);
if (local) {
setSlugStatus({kind: 'invalid', reason: local});
return;
}
setSlugStatus({kind: 'checking'});
try {
const res = await checkSiteSlug(placeId ?? '', slug);
setSlugStatus(
res.available
? {kind: 'available'}
: {kind: 'taken', suggestion: res.suggestion ?? undefined},
);
} catch (error) {
notifyApiError(error, '주소를 확인하지 못했습니다.');
setSlugStatus({kind: 'idle'});
}
}, [slug, placeId]);
const handlePublish = async () => {
// ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 —
// 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다.
if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return;
// ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
// 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
if (!isSlugLocked) {
try {
await reserveSiteSlug(placeId ?? '', slug);
} catch (error) {
notifyApiError(error, '주소를 확정하지 못했습니다.');
return;
}
}
// 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
publisher.publish();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
notify.error('주소를 복사하지 못했습니다', '주소를 직접 선택해 복사해 주세요.');
}
};
return (
<Dialog
open={isOpen}
onClose={close}
title={
blocker === 'signin'
? '지금 화면은 미리보기입니다'
: blocker === 'place'
? '가게 확인이 먼저입니다'
: isDone
? '발행 완료'
: isRepublish
? '재발행 전 점검'
: '발행 전 점검'
}
description={
blocker === 'signin'
? '만드신 화면은 아직 어디에도 올라가 있지 않습니다.'
: blocker === 'place'
? '발행본은 확인된 가게 한 곳에 묶입니다.'
: isDone
? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.'
: '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.'
}
className="max-w-lg"
footer={
<div className="flex items-center gap-2">
<Button variant="outline" className="flex-1" onClick={close}>
계속 편집하기
</Button>
{/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의
폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */}
{blocker === 'signin' ? null : blocker === 'place' ? (
<Button
variant="primary"
className="flex-1"
onClick={() => {
// 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라
// 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다.
close();
navigate(PLACE_SEARCH_URL);
}}
>
<Search />
<span>내 가게 확인하러 가기</span>
</Button>
) : isDone ? (
<Button
variant="primary"
className="flex-1"
onClick={() => {
window.open(url, '_blank', 'noopener,noreferrer');
close();
}}
>
<ExternalLink />
<span>사이트 열기</span>
</Button>
) : (
<Button
variant="primary"
className="flex-1"
onClick={() => void handlePublish()}
disabled={!gate.canPublish || publisher.isPublishing || !slugReady}
isLoading={publisher.isPublishing}
>
{!publisher.isPublishing &&
(state.phase === 'idle' ? <ArrowUpRight /> : <RefreshCw />)}
<span>
{publisher.isPublishing
? '빌드 중…'
: !gate.canPublish
? '발행 불가'
: state.phase === 'idle'
? isRepublish
? '지금 재발행하기'
: '지금 발행하기'
: '고쳤어요 · 다시 발행'}
</span>
</Button>
)}
</div>
}
>
<div className="space-y-4">
{blocker === 'signin' && <SignInFirstPanel />}
{blocker === 'place' && <PlaceFirstPanel />}
{/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */}
{!blocker && !isDone && (
<SiteStatusRow
version={publisher.currentVersion?.version}
needsRebuild={publisher.needsRebuild}
publishedAt={publisher.site?.published_at}
/>
)}
{/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다.
★ blocker 상태에서는 그리지 않는다. 주소 중복 확인이 사업장·토큰을 요구하므로
입력칸만 열어 두면 확인 버튼이 매번 실패한다. */}
{!blocker && !isDone && (
<SlugField
value={slug}
host={PUBLISH_HOST}
status={slugStatus}
disabled={isSlugLocked || publisher.isPublishing}
onChange={(next) => {
setSlug(next);
// 입력이 바뀌면 이전 확인 결과는 무효다 — 확인 없이 발행되지 않게 되돌린다.
setSlugStatus({kind: 'idle'});
}}
onCheck={() => void checkSlug()}
/>
)}
{!blocker && !isDone && gate.blockers.length > 0 && (
<section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-destructive">
<ShieldAlert className="size-3.5" />
발행을 막는 항목 {gate.blockers.length}건
</h3>
{gate.blockers.map((finding) => (
<FindingCard key={finding.id} finding={finding} />
))}
</section>
)}
{!blocker && !isDone && gate.warnings.length > 0 && (
<section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-warning">
<AlertTriangle className="size-3.5" />
발행은 되지만 노출에 손해 {gate.warnings.length}건
</h3>
{gate.warnings.map((finding) => (
<FindingCard key={finding.id} finding={finding} />
))}
</section>
)}
{!blocker && !isDone && gate.findings.length === 0 && state.phase === 'idle' && (
<div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/8 p-3 text-xs text-success">
<Check className="size-4 shrink-0" />
<span>
점검 항목을 모두 통과했습니다. {isRepublish ? '재발행하면' : '발행하면'} 정적
페이지(HTML)가 다시 구워집니다.
</span>
</div>
)}
{/* ★ 서버 판정. 화면 점검(gate)이 통과여도 여기서 막힐 수 있다 — 저장된 fact 기준이라 기준값이 다르다. */}
<ServerVerdict state={state} />
{placeId && (isDone || isRepublish) && <SocialPanel placeId={placeId} />}
{isDone && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 p-3 text-left">
<div className="min-w-0 flex-1">
<span className="block text-[10px] font-medium text-muted-foreground">
발행된 웹사이트 주소
{state.result?.version ? ` · v${state.result.version}` : ''}
</span>
<span className="block truncate font-mono text-xs font-bold">{url}</span>
</div>
<Button variant={copied ? 'secondary' : 'outline'} size="sm" onClick={handleCopy}>
{copied ? <Check className="text-success" /> : <Copy />}
<span className={cn(copied && 'text-success')}>{copied ? '복사됨' : '링크 복사'}</span>
</Button>
</div>
)}
</div>
</Dialog>
);
}
/**
* 로그인 전 — **미리보기라고 말하고, 그 자리에서 로그인을 받는다.**
*
* ★ /login 으로 튕기지 않는다. 위저드·에디터 상태는 브라우저에 저장하지 않으므로
* (stores/builder 주석) 화면을 떠나는 순간 지금까지 만든 것이 통째로 사라진다.
* 에디터 관문(EditorSignInGate)이 같은 이유로 같은 폼을 그 자리에 놓는다.
* ★ 로그인이 끝나면 폼이 심은 토큰으로 blocker 가 저절로 다시 계산된다 — 이 패널이 사라지고
* 발행 점검 화면이 뜬다(사업장이 아직 없으면 아래 PlaceFirstPanel 로 넘어간다).
*/
function SignInFirstPanel() {
return (
<div className="space-y-3">
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Eye className="size-3.5" />
<span>여기까지는 미리보기입니다</span>
</div>
<p className="leading-relaxed text-muted-foreground">
발행하면 검색·AI 가 읽는 정적 페이지(HTML)가 구워집니다. 그 페이지는 계정과 실제
가게에 묶이므로, 로그인과 내 가게 확인이 끝나야 시작할 수 있습니다.
</p>
<p className="mt-1.5 text-[11px] font-medium">
→ 여기서 로그인하시면 지금까지 만든 내용은 그대로 둔 채 이어집니다.
</p>
</div>
<div className="flex justify-center">
<SignInForm
submitLabel="로그인하고 발행 준비"
header={
<p className="text-center text-xs leading-relaxed text-muted-foreground">
발행본은 나중에 고치고 내릴 수 있어야 해서 계정에 묶어 둡니다.
</p>
}
/>
</div>
</div>
);
}
/**
* 로그인은 했는데 서버에 사업장이 없다 — 3단계에서 수집을 건너뛰고 나온 경로다.
*
* ★ 여기서 사업장을 몰래 만들지 않는다. 생성과 네이버 검증의 순서는
* features/onboarding/ensureServerPlace 한 곳이 소유한다 — 검증을 건너뛰고 만든 사업장은
* 수집도 발행도 못 하는 껍데기로 남고, 그 사실은 화면에 안 나온다.
*/
function PlaceFirstPanel() {
return (
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Store className="size-3.5" />
<span>내 가게가 아직 확인되지 않았습니다</span>
</div>
<p className="leading-relaxed text-muted-foreground">
발행본은 실제 가게 한 곳에 묶입니다 — 주소·전화·영업시간이 그 가게의 것이라는 근거가
있어야 검색·AI 가 이 페이지를 출처로 씁니다. 직접 입력으로만 진행하셨다면 그 가게가
아직 서버에 없어서, 발행을 눌러도 구울 대상이 없습니다.
</p>
<p className="mt-1.5 text-[11px] font-medium">
→ 상호로 내 가게를 찾아 확인하시면 그때부터 발행이 열립니다.
</p>
</div>
);
}
/** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */
/**
* 서버가 보는 현재 발행 상태 한 줄.
*
* ★ '재빌드 필요' 배지는 **발행 이력이 있을 때만** 뜬다.
* 서버의 needs_rebuild 는 발행본이 없을 때도 true 다(site_service: 버전이 없으면
* `bool(place.content_updated_at)`) — "구울 것이 있다"는 뜻이지 "다시 구워야 한다"가 아니다.
* 그걸 그대로 배지로 그리면 한 번도 발행한 적 없는 사이트가
* "아직 발행된 버전이 없습니다 · 재빌드 필요" 라고 자기모순을 말한다.
*/
function SiteStatusRow({
version,
needsRebuild,
publishedAt,
}: {
version?: number;
needsRebuild: boolean;
publishedAt?: string | null;
}) {
return (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-[11px]">
<span className="text-muted-foreground">
{version
? `현재 발행 버전 v${version}${publishedAt ? ` · ${publishedAt.slice(0, 10)}` : ''}`
: '아직 발행된 버전이 없습니다 — 발행하면 정적 페이지가 생성됩니다'}
</span>
{version && needsRebuild ? (
<Badge variant="warning" className="shrink-0 text-[10px]">
수정됨 · 재발행 필요
</Badge>
) : null}
</div>
);
}
/**
* 빌드 잡이 끝난 뒤의 서버 판정.
*
* ★ 게이트 거부와 빌드 실패를 다르게 그린다. 거부는 "고칠 곳이 있다"이고
* 실패는 "우리 쪽 문제"다 — 같은 빨간 상자로 묶으면 사장님이 뭘 해야 할지 모른다.
*/
function ServerVerdict({state}: {state: PublishState}) {
if (state.phase === 'building') {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 p-3 text-xs">
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
<span>
스냅샷을 박제해 정적 페이지와 구조화 데이터를 굽고 있습니다. 몇 십 초 걸립니다.
</span>
</div>
);
}
if (state.phase === 'rejected' && state.result?.gate) {
return <GateRejectCard gate={state.result.gate} />;
}
if (state.phase === 'failed') {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-destructive">
<ServerCrash className="size-3.5" />
<span>빌드하지 못했습니다</span>
</div>
<p className="leading-relaxed break-words text-muted-foreground">
{state.error ?? '잠시 후 다시 시도해 주세요. 계속 실패하면 운영자에게 알려주세요.'}
</p>
</div>
);
}
return null;
}
/** 서버 발행 검수 게이트가 막은 이유. `publish_logs.reject_reason` 과 같은 값이다. */
function GateRejectCard({gate}: {gate: BuildGateResult}) {
const reason = gate.reason ?? '';
const detail = describeGate(gate);
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/8 p-3 text-xs">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 font-bold text-destructive">
<ShieldAlert className="size-3.5" />
{GATE_REASON_LABEL[reason] ?? '서버 검수를 통과하지 못했습니다'}
</span>
</div>
{detail && <p className="leading-relaxed break-words text-muted-foreground">{detail}</p>}
<p className="mt-1.5 text-[11px] font-medium">
→ 서버는 저장된 값으로 판정합니다. 오른쪽 [확인]·[정보] 탭에서 고친 뒤 다시 발행해 주세요.
</p>
</div>
);
}
/** 거부 사유별 상세를 한 줄로. 사유마다 실려 오는 키가 다르다(publish_gate.GateResult.detail). */
function describeGate(gate: BuildGateResult): string | null {
if (gate.unverified?.length) {
const keys = gate.unverified.slice(0, 5).map((f) => f.key).join(' · ');
const rest = (gate.count ?? gate.unverified.length) - Math.min(5, gate.unverified.length);
return `${keys}${rest > 0 ? ` 외 ${rest}건` : ''}이 아직 확인 전입니다.`;
}
if (gate.missing?.length) {
return `${(gate.labels?.length ? gate.labels : gate.missing).join(' · ')} 이(가) 비어 있습니다.`;
}
if (gate.mismatches?.length) {
return `화면과 구조화 데이터가 다른 항목: ${gate.mismatches.slice(0, 5).join(' · ')}`;
}
if (gate.unique_content_count !== undefined) {
return '이 가게에만 있는 문장(소개문·FAQ·객실 설명·사진 설명)이 한 건도 없습니다.';
}
return null;
}
function FindingCard({finding}: {finding: GateFinding}) {
const isBlocker = finding.severity === 'block';
return (
<div
className={cn(
'rounded-lg border p-3 text-xs',
isBlocker
? 'border-destructive/30 bg-destructive/8'
: 'border-warning/40 bg-warning/8',
)}
>
<div className="mb-1 flex items-center justify-between gap-2">
{/* ★ 거절 사유 코드는 띄우지 않는다 — 사장님 화면에 개발용 식별자가 나갈 자리가 아니다.
(사유는 publish_logs 에 그대로 남으므로 운영 쪽에서 추적할 수 있다.) */}
<span className="font-bold">{finding.title}</span>
</div>
<p className="leading-relaxed text-muted-foreground">{finding.detail}</p>
{finding.fix && (
<p className="mt-1.5 text-[11px] font-medium">→ {finding.fix}</p>
)}
</div>
);
}