o2o-site-AEO/solution/frontend/src/pages/BuilderPage.tsx
Mina Choi 5ef3e5a7de 업종 4번째를 관광체험 → 피부과·성형외과 로 바꾸고, 로그인 관문을 에디터 진입으로 되돌린다
## 업종 교체 (tour → clinic)

PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다.

- category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시
  운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다.
  unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항).
- 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 —
  이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다.
- jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다.
- 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로.
- mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다.
- site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 —
  test_site_theme 이 이 둘을 대조한다.

## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert)

두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다.
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다.
위저드를 열어 두고 에디터 진입에서 한 번 받는 969fb67 설계로 되돌린다.
d6a6c8e 가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다.

## 그 밖

- test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락).
- .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면
  nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다.

검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed —
그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
2026-09-02 15:42:34 +09:00

235 lines
11 KiB
TypeScript

import {useEffect, useRef} from 'react';
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import {
Step1Industry,
Step2PlaceSearch,
Step3DataReview,
Step4Template,
Step5Generating,
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
/**
* "발행본 사이트 열기" 가 향할 주소.
*
* ★ 예전엔 서버 루트(:3001)만 열었다. 사이트가 하나뿐이던 시절의 흔적인데, 지금은
* 여러 사이트가 `/s/<주소>` 아래 놓여서 루트를 열면 아무것도 안 나온다.
* 사장님이 정한 주소(sites.domain)가 있으면 그 사이트로 보낸다.
* ★ 도메인이 없으면 null 이다 — 예전엔 서버 루트로 떨어뜨렸는데, 그건 발행 전에도
* 버튼이 열려 있고 누르면 빈 화면이 뜬다는 뜻이었다. 열 곳이 없으면 열지 않는다.
*/
function siteUrl(domain: string | null | undefined): string | null {
return domain ? `${SITE_PREVIEW_URL}/s/${domain}` : null;
}
export function BuilderPage() {
useAutoLogin();
/**
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`.
*
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고
* (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
*/
const [searchParams, setSearchParams] = useSearchParams();
const urlPlaceId = searchParams.get('placeId');
/**
* `?new=1` 로 들어오면 위저드를 1단계(업종 선택)부터 시작한다.
*
* ★ 저장된 상태를 그대로 두면 지난번 에디터가 복원된다 — 새 가게를 만들러 온 사람에게는
* 자기가 만든 적 없는 화면이 뜨는 셈이다. 비운 뒤에는 주소창에서 플래그를 지워,
* 새로고침할 때마다 작업하던 내용이 날아가지 않게 한다.
*/
const reset = useBuilderStore((s) => s.reset);
const isNew = searchParams.get('new') === '1';
useEffect(() => {
if (!isNew) return;
reset();
setSearchParams({}, {replace: true});
}, [isNew, reset, setSearchParams]);
/**
* 위저드 2단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다.
*
* ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 —
* 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다.
* 딥링크(/builder?placeId=...)가 우선이다 — 사업장 목록에서 다른 가게를 열 수 있어야 한다.
*/
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
const placeId = urlPlaceId ?? wizardPlaceId;
/**
* 에디터로 바로 들어갈지.
*
* ★ **처음 들어온 순간**의 주소창만 본다. 위저드 2단계가 확정 뒤에 `?placeId=` 를 붙이는데
* (새로고침 복원용), 그걸 실시간으로 보면 "URL 붙여넣고 확인 → 곧장 에디터" 가 된다 —
* 수집 결과를 보여주는 3·4·5단계가 통째로 건너뛰어진다. 실제로 그렇게 됐다.
* 사업장 목록에서 딥링크로 들어온 경우(이미 만든 가게를 여는 것)만 에디터로 보낸다.
*/
const enteredWithPlace = useRef(
Boolean(urlPlaceId) && searchParams.get('flow') !== 'onboarding',
).current;
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
const step = useBuilderStore((s) => s.step);
const goToStep = useBuilderStore((s) => s.goToStep);
const storeName = useBuilderStore((s) => s.storeName);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const authUser = useAuthStore((s) => s.user);
const isSignedIn = Boolean(authUser) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId);
/**
* 발행본이 실제로 존재하는가.
*
* ★ 주소(domain)만으로는 부족하다 — 주소는 발행 **전에** 예약된다(PublishModal 이
* 빌드보다 먼저 잡아 둔다). 주소만 보고 버튼을 열면 아직 굽지 않은 사이트로
* 보내 404 를 띄운다. 사이트 상태가 PUBLISHED 인 것까지 확인한다.
*/
const publishedUrl =
sync.site?.status === SiteStatus.PUBLISHED ? siteUrl(sync.site.domain) : null;
// 실사업장을 열었는데 아직 못 읽었다 — 이 동안 데모(달빛스테이)를 그리면
// 사장님은 남의 가게를 자기 가게로 오해한다. 차라리 아무것도 안 그린다.
if (placeId && sync.isLoading) {
return (
<BuilderNotice title="사업장을 불러오는 중입니다" description={placeId} isLoading />
);
}
if (placeId && (sync.isError || sync.isNotFound)) {
return (
<BuilderNotice
title={sync.isNotFound ? '사업장을 찾지 못했습니다' : '사업장을 불러오지 못했습니다'}
description={
sync.isNotFound
? '주소의 placeId 가 맞는지 확인해 주세요.'
: ((sync.error as Error)?.message ??
'로그인이 필요한 데이터입니다. 백엔드가 떠 있는지, 로그인돼 있는지 확인해 주세요.')
}
/>
);
}
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
if (step === EDITOR_STEP && !isSignedIn) {
return <EditorSignInGate onBack={() => goToStep(4)} />;
}
if (step === EDITOR_STEP) {
return (
<div className="relative flex h-screen w-screen flex-col overflow-hidden">
<div className="z-40 flex items-center justify-between gap-2 border-b border-border bg-foreground px-4 py-1.5 text-[11px] text-background">
<div className="flex min-w-0 items-center gap-2">
<span className="rounded bg-white/10 px-2 py-0.5 font-mono text-[10px] font-semibold">
AI-FOR-WEB BUILDER
</span>
{/* 지금 화면이 실제 사업장인지 시연용 데모인지 한눈에 구분되게 둔다. */}
{wiredPlaceId ? (
<>
<span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success">
· {storeName}
</span>
{/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로
나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를
새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다.
사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */}
</>
) : (
<span className="truncate opacity-80">
[ ] .
</span>
)}
</div>
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
{publishedUrl ? (
<a
href={publishedUrl}
target="_blank"
rel="noopener noreferrer"
className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90"
>
<ExternalLink className="size-3" />
<span> </span>
</a>
) : (
<span
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
aria-disabled="true"
className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50"
>
<ExternalLink className="size-3" />
<span> </span>
</span>
)}
</div>
<div className="min-h-0 flex-1">
<EditorLayout />
</div>
</div>
);
}
// 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다.
// 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다.
return (
<AppShell>
<div className="flex h-full min-h-full flex-col">
{step === 1 && <Step1Industry />}
{step === 2 && <Step2PlaceSearch />}
{step === 3 && <Step3DataReview />}
{step === 4 && <Step4Template />}
{step === 5 && <Step5Generating />}
</div>
</AppShell>
);
}
/** 실사업장을 못 읽었을 때의 전체 화면. 데모로 돌아갈 길을 항상 같이 준다. */
function BuilderNotice({
title,
description,
isLoading,
}: {
title: string;
description: string;
isLoading?: boolean;
}) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-3 bg-muted px-6 text-center">
{isLoading ? (
<Loader2 className="size-6 animate-spin text-muted-foreground" />
) : (
<TriangleAlert className="size-6 text-warning" />
)}
<p className="text-sm font-bold">{title}</p>
<p className="max-w-md break-all text-xs text-muted-foreground">{description}</p>
{!isLoading && (
<Link
to="/builder"
className="mt-1 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium transition-colors hover:bg-background"
>
</Link>
)}
</div>
);
}