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({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 ( {/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의 폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */} {blocker === 'signin' ? null : blocker === 'place' ? ( ) : isDone ? ( ) : ( )} } >
{blocker === 'signin' && } {blocker === 'place' && } {/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */} {!blocker && !isDone && ( )} {/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. ★ blocker 상태에서는 그리지 않는다. 주소 중복 확인이 사업장·토큰을 요구하므로 입력칸만 열어 두면 확인 버튼이 매번 실패한다. */} {!blocker && !isDone && ( { setSlug(next); // 입력이 바뀌면 이전 확인 결과는 무효다 — 확인 없이 발행되지 않게 되돌린다. setSlugStatus({kind: 'idle'}); }} onCheck={() => void checkSlug()} /> )} {!blocker && !isDone && gate.blockers.length > 0 && (

발행을 막는 항목 {gate.blockers.length}건

{gate.blockers.map((finding) => ( ))}
)} {!blocker && !isDone && gate.warnings.length > 0 && (

발행은 되지만 노출에 손해 {gate.warnings.length}건

{gate.warnings.map((finding) => ( ))}
)} {!blocker && !isDone && gate.findings.length === 0 && state.phase === 'idle' && (
점검 항목을 모두 통과했습니다. {isRepublish ? '재발행하면' : '발행하면'} 정적 페이지(HTML)가 다시 구워집니다.
)} {/* ★ 서버 판정. 화면 점검(gate)이 통과여도 여기서 막힐 수 있다 — 저장된 fact 기준이라 기준값이 다르다. */} {placeId && (isDone || isRepublish) && } {isDone && (
발행된 웹사이트 주소 {state.result?.version ? ` · v${state.result.version}` : ''} {url}
)}
); } /** * 로그인 전 — **미리보기라고 말하고, 그 자리에서 로그인을 받는다.** * * ★ /login 으로 튕기지 않는다. 위저드·에디터 상태는 브라우저에 저장하지 않으므로 * (stores/builder 주석) 화면을 떠나는 순간 지금까지 만든 것이 통째로 사라진다. * 에디터 관문(EditorSignInGate)이 같은 이유로 같은 폼을 그 자리에 놓는다. * ★ 로그인이 끝나면 폼이 심은 토큰으로 blocker 가 저절로 다시 계산된다 — 이 패널이 사라지고 * 발행 점검 화면이 뜬다(사업장이 아직 없으면 아래 PlaceFirstPanel 로 넘어간다). */ function SignInFirstPanel() { return (
여기까지는 미리보기입니다

발행하면 검색·AI 가 읽는 정적 페이지(HTML)가 구워집니다. 그 페이지는 계정과 실제 가게에 묶이므로, 로그인과 내 가게 확인이 끝나야 시작할 수 있습니다.

→ 여기서 로그인하시면 지금까지 만든 내용은 그대로 둔 채 이어집니다.

발행본은 나중에 고치고 내릴 수 있어야 해서 계정에 묶어 둡니다.

} />
); } /** * 로그인은 했는데 서버에 사업장이 없다 — 3단계에서 수집을 건너뛰고 나온 경로다. * * ★ 여기서 사업장을 몰래 만들지 않는다. 생성과 네이버 검증의 순서는 * features/onboarding/ensureServerPlace 한 곳이 소유한다 — 검증을 건너뛰고 만든 사업장은 * 수집도 발행도 못 하는 껍데기로 남고, 그 사실은 화면에 안 나온다. */ function PlaceFirstPanel() { return (
내 가게가 아직 확인되지 않았습니다

발행본은 실제 가게 한 곳에 묶입니다 — 주소·전화·영업시간이 그 가게의 것이라는 근거가 있어야 검색·AI 가 이 페이지를 출처로 씁니다. 직접 입력으로만 진행하셨다면 그 가게가 아직 서버에 없어서, 발행을 눌러도 구울 대상이 없습니다.

→ 상호로 내 가게를 찾아 확인하시면 그때부터 발행이 열립니다.

); } /** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */ /** * 서버가 보는 현재 발행 상태 한 줄. * * ★ '재빌드 필요' 배지는 **발행 이력이 있을 때만** 뜬다. * 서버의 needs_rebuild 는 발행본이 없을 때도 true 다(site_service: 버전이 없으면 * `bool(place.content_updated_at)`) — "구울 것이 있다"는 뜻이지 "다시 구워야 한다"가 아니다. * 그걸 그대로 배지로 그리면 한 번도 발행한 적 없는 사이트가 * "아직 발행된 버전이 없습니다 · 재빌드 필요" 라고 자기모순을 말한다. */ function SiteStatusRow({ version, needsRebuild, publishedAt, }: { version?: number; needsRebuild: boolean; publishedAt?: string | null; }) { return (
{version ? `현재 발행 버전 v${version}${publishedAt ? ` · ${publishedAt.slice(0, 10)}` : ''}` : '아직 발행된 버전이 없습니다 — 발행하면 정적 페이지가 생성됩니다'} {version && needsRebuild ? ( 수정됨 · 재발행 필요 ) : null}
); } /** * 빌드 잡이 끝난 뒤의 서버 판정. * * ★ 게이트 거부와 빌드 실패를 다르게 그린다. 거부는 "고칠 곳이 있다"이고 * 실패는 "우리 쪽 문제"다 — 같은 빨간 상자로 묶으면 사장님이 뭘 해야 할지 모른다. */ function ServerVerdict({state}: {state: PublishState}) { if (state.phase === 'building') { return (
스냅샷을 박제해 정적 페이지와 구조화 데이터를 굽고 있습니다. 몇 십 초 걸립니다.
); } if (state.phase === 'rejected' && state.result?.gate) { return ; } if (state.phase === 'failed') { return (
빌드하지 못했습니다

{state.error ?? '잠시 후 다시 시도해 주세요. 계속 실패하면 운영자에게 알려주세요.'}

); } return null; } /** 서버 발행 검수 게이트가 막은 이유. `publish_logs.reject_reason` 과 같은 값이다. */ function GateRejectCard({gate}: {gate: BuildGateResult}) { const reason = gate.reason ?? ''; const detail = describeGate(gate); return (
{GATE_REASON_LABEL[reason] ?? '서버 검수를 통과하지 못했습니다'}
{detail &&

{detail}

}

→ 서버는 저장된 값으로 판정합니다. 오른쪽 [확인]·[정보] 탭에서 고친 뒤 다시 발행해 주세요.

); } /** 거부 사유별 상세를 한 줄로. 사유마다 실려 오는 키가 다르다(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 (
{/* ★ 거절 사유 코드는 띄우지 않는다 — 사장님 화면에 개발용 식별자가 나갈 자리가 아니다. (사유는 publish_logs 에 그대로 남으므로 운영 쪽에서 추적할 수 있다.) */} {finding.title}

{finding.detail}

{finding.fix && (

→ {finding.fix}

)}
); }