o2o-site-AEO/solution/frontend/src/pages/BuilderPage.tsx
Mina Choi 94551afdaf [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프
가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.

- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
  body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
  실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
  잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
  순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
  `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
  회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
  스코프가 없으면 아무에게도 안 보이는 유령이다.
  실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
  격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다

남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.

검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:14 +09:00

315 lines
14 KiB
TypeScript

import {useEffect} from 'react';
import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router';
import {SiteStatus, type IndustryType} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import {
EDITOR_STEP,
Step1Industry,
Step2PlaceSearch,
Step3DataReview,
Step4Template,
Step5Generating,
useWizardStep,
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {userLabel, useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder';
import {ORIGIN} from '@/lib/site';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL || ORIGIN;
/** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */
const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
function parseIndustry(value: string | null): IndustryType | null {
return INDUSTRY_VALUES.includes(value as IndustryType) ? (value as IndustryType) : null;
}
/**
* "발행본 사이트 열기" 가 향할 주소.
*
* ★ 예전엔 서버 루트(: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();
/**
* ★ 이 화면이 무엇을 그릴지는 **전부 주소창이 정한다.**
*
* ?step= 어느 단계인가(없으면 wizardUrl.defaultStep 이 정한다)
* ?placeId= 어떤 사업장인가 — 라우트(`/builder/:placeId`)로 받지 않는 이유는
* 빌더가 로그인 없이 도는 경로이고 placeId 는 있을 수도 없을 수도 있어서다.
* ?flow=onboarding 위저드가 방금 만든 사업장이다(딥링크로 편집하러 온 것과 구분한다)
* ?new=1 ?q= ?industry= 랜딩에서 넘어온 입구. 한 번 읽고 주소창에서 지운다.
*/
const [searchParams, setSearchParams] = useSearchParams();
const [step, goToStep] = useWizardStep();
const urlPlaceId = searchParams.get('placeId');
const isOnboarding = searchParams.get('flow') === 'onboarding';
/**
* 랜딩에서 넘어온 입구를 한 번만 읽는다 — `?new=1` · `?q=` · `?industry=`.
*
* ★ `?new=1` 은 저장된 상태를 비운다. 안 비우면 새 가게를 만들러 온 사람에게 지난번 에디터가
* 복원돼 뜬다. 읽은 뒤 주소창에서 지우는 이유는 두 가지다 — 새로고침마다 작업하던 내용이
* 날아가지 않게, 그리고 사장님이 화면에서 고친 상호·업종을 링크 값이 도로 덮지 않게.
* ★ **다른 쿼리는 남긴다.** 예전엔 `setSearchParams({})` 로 통째로 비웠는데, 그러면
* `?new=1&q=...` 로 들어온 상호가 읽히기도 전에 사라진다.
*/
const reset = useBuilderStore((s) => s.reset);
const selectIndustry = useBuilderStore((s) => s.selectIndustry);
const setStoreName = useBuilderStore((s) => s.setStoreName);
const isNew = searchParams.get('new') === '1';
const seedQuery = searchParams.get('q');
const seedIndustry = searchParams.get('industry');
useEffect(() => {
if (!isNew && seedQuery === null && seedIndustry === null) return;
if (isNew) reset();
// 순서가 뒤집히면 안 된다 — selectIndustry 는 시드를 통째로 갈아 상호를 비운다.
const industry = parseIndustry(seedIndustry);
if (industry) selectIndustry(industry);
if (seedQuery) setStoreName(seedQuery);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('new');
next.delete('q');
next.delete('industry');
return next;
},
{replace: true},
);
}, [isNew, seedQuery, seedIndustry, reset, selectIndustry, setStoreName, setSearchParams]);
/**
* 위저드 1단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다.
*
* ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 —
* 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다.
* 딥링크(/builder?placeId=...)가 우선이다 — 사업장 목록에서 다른 가게를 열 수 있어야 한다.
*/
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
const placeId = urlPlaceId ?? wizardPlaceId;
const sync = usePlaceSync(placeId, {isOnboarding});
const storeName = useBuilderStore((s) => s.storeName);
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const isSignedIn = Boolean(user) || 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('template')} />;
}
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>
{/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */}
<Link
to="/sites"
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<ArrowLeft className="size-3" />
<span>내 사이트</span>
</Link>
</>
) : (
<span className="truncate opacity-80">
편집한 내용은 [사이트 발행] 을 눌러야 실제 페이지로 구워집니다.
</span>
)}
</div>
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
<div className="flex shrink-0 items-center gap-2">
{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>
)}
{user && (
<>
<span className="h-3.5 w-px bg-white/20" />
<span
className="max-w-[14rem] truncate text-background/70"
title={userLabel(user)}
>
{userLabel(user)}
</span>
<button
type="button"
onClick={signOut}
className="flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-background/70 transition-colors hover:bg-white/10 hover:text-background"
>
<LogOut className="size-3" />
<span>로그아웃</span>
</button>
</>
)}
</div>
</div>
<div className="min-h-0 flex-1">
<EditorLayout />
</div>
</div>
);
}
/**
* 위저드는 **사이드바를 쓰지 않는다.**
*
* ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를
* 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
* 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다.
*/
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
<Link to="/" className="transition-opacity hover:opacity-60">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-6 w-auto" />
</Link>
{/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */}
{isSignedIn ? (
<Link
to="/sites"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-3.5" />
내 사이트
</Link>
) : (
<Link
to="/login"
className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
로그인
</Link>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{step === 'search' && <Step2PlaceSearch />}
{step === 'industry' && <Step1Industry />}
{step === 'collect' && <Step3DataReview />}
{step === 'template' && <Step4Template />}
{step === 'generating' && <Step5Generating />}
</div>
</div>
);
}
/** 실사업장을 못 읽었을 때의 전체 화면. 데모로 돌아갈 길을 항상 같이 준다. */
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>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default BuilderPage;