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 ( ); } if (placeId && (sync.isError || sync.isNotFound)) { return ( ); } // 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다. if (step === EDITOR_STEP && !isSignedIn) { return goToStep('template')} />; } if (step === EDITOR_STEP) { return (
AI-FOR-WEB BUILDER {/* 지금 화면이 실제 사업장인지 시연용 데모인지 한눈에 구분되게 둔다. */} {wiredPlaceId ? ( <> 실사업장 · {storeName} {/* 돌아가는 길. 로그인한 사람에게만 목록이 있다(비로그인은 에디터에 못 들어온다). */} 내 사이트 ) : ( 편집한 내용은 [사이트 발행] 을 눌러야 실제 페이지로 구워집니다. )}
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 — 같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다. ★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
{publishedUrl ? ( 발행본 사이트 열기 ) : ( 발행본 사이트 열기 )} {user && ( <> {userLabel(user)} )}
); } /** * 위저드는 **사이드바를 쓰지 않는다.** * * ★ 사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 사이트 메뉴를 * 얹으면, 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. * 진행은 단계가 이미 보여주므로(WizardSteps) 여기 필요한 건 로고와 **나가는 길** 하나다. */ return (
Web4Ai {/* 비로그인은 돌아갈 목록이 없다 — 그 자리에는 로그인을 둔다(빈 버튼을 두지 않는다). */} {isSignedIn ? ( 내 사이트 ) : ( 로그인 )}
{step === 'search' && } {step === 'industry' && } {step === 'collect' && } {step === 'template' && } {step === 'generating' && }
); } /** 실사업장을 못 읽었을 때의 전체 화면. 데모로 돌아갈 길을 항상 같이 준다. */ function BuilderNotice({ title, description, isLoading, }: { title: string; description: string; isLoading?: boolean; }) { return (
{isLoading ? ( ) : ( )}

{title}

{description}

{!isLoading && ( 데모로 열기 )}
); } // 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). export default BuilderPage;