import {useMemo} from 'react'; import {create} from 'zustand'; import type { IndustryType, InfoField, PhotoItem, SectionItem, TemplateItem, ViewportMode, } from '@o2o/shared'; import {FALLBACK_INDUSTRY, INDUSTRY_CONFIGS} from '@/data/industryData'; import {addableSection, newSectionOf} from '@/features/builder/canvas/addable'; import {COLOR_PALETTE_PRESETS} from '@/features/builder/colorPalettes'; import {createFactSaver, type FactSaver} from '@/features/builder/factSave'; import { clearThemeSaves, queueSiteThemeSave, toThemePayload, type SiteThemePayload, } from '@/features/publish/siteTheme'; import type { ApplyPlaceOptions, ConfirmedIdentity, FactRef, LivePlaceInput, PendingPick, RightTab, WeatherLocation, } from '@/stores/builderTypes'; /** * ★ 화면 계약 타입은 stores/builderTypes 가 소유한다. 여기서 다시 내보내는 이유는 * 호출부가 "빌더 스토어에서 가져온다"는 감각을 유지하게 하기 위해서다 — * 타입만 필요한 모듈(어댑터·저장 파이프라인)은 builderTypes 를 직접 본다. */ export type { ApplyPlaceOptions, ConfirmedIdentity, FactRef, LivePlaceInput, PendingPick, RightTab, WeatherLocation, }; interface BuilderState { // ── 위저드 ──────────────────────────────────────────── // ★ 단계(step)는 여기 없다 — 주소창이 소유한다(features/onboarding/wizardUrl). industry: IndustryType; /** * 지금 편집 중인 실제 사업장. null 이면 아직 가게가 정해지지 않은 상태다. * * ★ 값이 null 이어도 화면을 시연용 데이터로 채우지 않는다. 캔버스는 언제나 * 서버에서 온 것만 그리고, 없으면 빈 상태를 그린다. */ placeId: string | null; storeName: string; location: string; weatherLocation?: WeatherLocation; selectedChannels: string[]; /** * 2단계에서 확인된 신원. null 이면 아직 "이 가게가 맞다"를 아무도 말하지 않았다 — * 화면의 상호·주소는 업종 예시일 뿐이라는 뜻이고, 3단계는 그 사실을 그대로 표시한다. */ confirmedIdentity: ConfirmedIdentity | null; /** 업종을 못 정해 업종 화면으로 넘긴 후보. 돌아와서 확정을 이어갈 때만 쓴다. */ pendingPick: PendingPick | null; // 수집 isGathering: boolean; gatherStage: number; gatherCompleted: boolean; // 템플릿 · 생성 templateId: string; colorPaletteId: string | null; // ── 편집 대상 데이터 ─────────────────────────────────── sections: SectionItem[]; infoFields: InfoField[]; photos: PhotoItem[]; /** * infoFields 의 id → 서버 fact. 데모에서는 항상 비어 있다(전이시킬 fact 자체가 없다). * 신원 줄(상호·주소·전화)도 여기 없다 — place 의 값이지 fact 가 아니다. */ factRefs: Record; /** 지금 서버로 올라가는 중인 줄. 사장님에게 "저장 중"을 보여주는 근거. */ savingFieldIds: string[]; // ── 에디터 UI ───────────────────────────────────────── selectedSectionId: string | null; viewport: ViewportMode; rightTab: RightTab; isPreviewMode: boolean; isPublishModalOpen: boolean; publishedUrl: string | null; // ── 액션 ────────────────────────────────────────────── selectIndustry: (industry: IndustryType) => void; /** 실사업장 데이터를 캔버스에 얹는다(같은 사업장을 다시 읽어도 편집을 지우지 않는다). */ applyPlace: (input: LivePlaceInput, options?: ApplyPlaceOptions) => void; /** 배선을 풀고 데모로 되돌린다. */ clearPlace: () => void; setStoreName: (name: string) => void; setLocation: (location: string) => void; toggleChannel: (channelId: string) => void; /** 2단계: 사람이 고른 후보(또는 직접 입력)를 이 사업장의 신원으로 확정한다. */ confirmIdentity: (identity: ConfirmedIdentity) => void; /** 확정을 물린다 — 상호를 다시 검색하러 갈 때. */ clearIdentity: () => void; /** 업종 화면으로 넘길 후보를 들려 보낸다(null 이면 비운다). */ setPendingPick: (pick: PendingPick | null) => void; startGather: () => void; setGatherStage: (stage: number) => void; finishGather: () => void; selectTemplate: (templateId: string) => void; selectColorPalette: (paletteId: string | null) => void; /** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */ selectSection: (id: string | null) => void; toggleSection: (id: string) => void; reorderSection: (fromIndex: number, toIndex: number) => void; /** [+ 섹션 추가] — 이미 있으면 켜기만 한다. */ addSection: (type: string) => void; /** 나중에 넣은 섹션만 뺀다. 업종 기본 섹션은 스위치로 끄는 것이지 빼는 게 아니다. */ removeSection: (id: string) => void; updateSectionContent: (sectionId: string, patch: Pick) => void; /** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */ updateSectionData: (sectionId: string, data: string) => void; setSectionVariant: (sectionId: string, variantId: string) => void; resetSectionVariants: () => void; /** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */ applyTheme: (theme: SiteThemePayload | null | undefined, savedTemplateId?: string | null) => void; updateField: (id: string, value: string) => void; updateFieldLabel: (id: string, label: string) => void; addInfoField: (label: string, value: string) => void; removeInfoField: (id: string) => void; verifyField: (id: string, correctedValue?: string) => void; setPrimaryPhoto: (photoId: string) => void; togglePhotoVisibility: (photoId: string) => void; setViewport: (viewport: ViewportMode) => void; setRightTab: (tab: RightTab) => void; togglePreview: () => void; openPublishModal: () => void; closePublishModal: () => void; setPublishedUrl: (url: string | null) => void; reset: () => void; } /** * 위저드 입력이 소유하는 줄의 id. 업종 시드·서버 매핑 모두 같은 id 를 쓴다. * 이 두 줄만은 "수집된 값"이 아니라 **사장님이 직접 친 값**이 근거다. * (아래쪽 IDENTITY_FIELD_IDS 는 전화까지 포함한 'fact 가 아닌 줄' 목록으로, 쓰임이 다르다.) */ const OWNER_INPUT_FIELD_IDS = {name: 'name', address: 'address'} as const; /** 사장님이 직접 입력한 값의 출처 표기. 백엔드의 SourceType.OWNER 에 대응한다. */ const OWNER_SOURCE = '직접 입력'; /** * 위저드에서 사장님이 친 상호·위치를 정보 카드에 그대로 얹는다. * * ★ 아직 사업장이 확정되지 않은 동안(placeId === null) 화면에 보일 값은 사장님이 * 직접 친 상호·위치뿐이다. 업종 시드에는 콘텐츠가 없다 — 예전에는 여기에 예시 가게 * ('달빛스테이 제주')가 들어 있어서, 사장님이 '스테이머뭄 / 군산'을 쳐도 화면은 * 계속 남의 가게를 자기 가게라고 말했다. * * ★ 실사업장의 상호·주소는 place 레코드가 소유하고 서버 응답이 유일한 근거다 — * 여기서 덮으면 화면과 DB 가 갈린다. * * 상호는 사장님 본인이 댄 값이라 확인 절차가 없다(OWNER 는 그 자체로 검증된 출처다). * 위치는 '시/군/구/동'만 받으므로 주소 한 줄로는 부족하다 — 확인 대상으로 남겨 * 발행 전에 전체 주소를 받게 한다. */ /** * 확정된 신원을 정보 카드에 얹는다. * * 상호·주소·전화는 사장님이 후보 카드를 보고 "내 가게가 맞다"고 고른 값이다 — * 그 클릭 자체가 확인이므로 [확인 필요]를 달지 않는다. 직접 입력한 주소도 마찬가지로 * 본인이 댄 값이라 확인 대상이 아니다(틀리면 본인이 고친다). */ function withConfirmedIdentity(fields: InfoField[], identity: ConfirmedIdentity): InfoField[] { const patch: Record = { [OWNER_INPUT_FIELD_IDS.name]: identity.name.trim() || undefined, [OWNER_INPUT_FIELD_IDS.address]: identity.address.trim() || undefined, phone: identity.phone?.trim() || undefined, }; return fields.map((field) => { const value = patch[field.id]; if (!value) return field; return {...field, value, source: identity.sourceLabel, requiresVerification: false, isVerified: true}; }); } function withOwnerIdentity( fields: InfoField[], storeName: string, location: string, ): InfoField[] { const name = storeName.trim(); const address = location.trim(); return fields.map((field) => { if (field.id === OWNER_INPUT_FIELD_IDS.name && name) { return {...field, value: name, source: OWNER_SOURCE, requiresVerification: false, isVerified: true}; } if (field.id === OWNER_INPUT_FIELD_IDS.address && address) { return {...field, value: address, source: OWNER_SOURCE, requiresVerification: true, isVerified: false}; } return field; }); } /** * 업종을 고른 직후의 빈 상태. * * ★ 콘텐츠 시드가 없다. 예전에는 여기서 상호('달빛스테이 제주')·주소·정보표·사진까지 * 업종 시드로 채웠다. 그래서 아직 아무 가게도 고르지 않았는데 화면이 남의 가게로 * 가득 차 있었고, 사장님은 그걸 자기 가게 내용으로 읽었다. * 구조(섹션 목록·템플릿·채널)만 시드가 소유하고, 값은 전부 서버에서 온다. */ function seedFor(industry: IndustryType) { const config = INDUSTRY_CONFIGS[industry]; return { industry, storeName: '', location: '', weatherLocation: undefined, selectedChannels: config.channels.filter((c) => c.checked).map((c) => c.id), templateId: config.templates[0].id, colorPaletteId: null, // 배열은 반드시 사본으로 넣는다. 그대로 넣으면 에디터의 순서 변경이 // INDUSTRY_CONFIGS 원본을 건드려, 업종을 되돌아왔을 때 초기값이 오염된다. sections: config.sections.map((s) => ({...s})), infoFields: [], photos: [], factRefs: {} as Record, }; } /** * ★ 위저드 상태를 브라우저에 저장하지 않는다. * * 한때 localStorage(뒤에 sessionStorage)에 저장했다. 수집이 몇 분 걸리는 잡이라 * 그 사이 새로고침이 나면 "어느 가게로 확정했는지"가 날아가 1단계로 튕겼기 때문이다. * 그런데 그 저장이 더 나쁜 문제를 만들었다 — **새 가게를 만들러 2단계에 들어가면 * 지난번 가게가 "이 가게로 확인되었습니다"로 떠 있다.** 사장님 눈에는 자기가 고르지도 * 않은 가게가 확정된 것으로 보인다. * * 진짜 복원 수단은 따로 있다: 신원이 확정되면 주소창에 `?placeId=...` 가 붙고 * (Step2PlaceSearch), 새로고침하면 usePlaceSync 가 **서버에서** 다시 읽는다. * 서버가 진실이므로 브라우저 사본이 필요 없고, 주소를 새로 열면 자연히 깨끗하다. * * 옛 저장분은 여기서 지운다 — 이미 사용자 브라우저에 들어가 있기 때문이다. */ // ★ 프로젝트 이름이 web4ai 로 바뀌어도 이 키는 그대로 둔다 — 이미 사용자 브라우저에 // 들어가 있는 **옛 저장분의 이름**이라, 바꾸면 지워야 할 것을 못 지운다. for (const key of ['o2osite.builder.wizard.v1', 'o2osite.builder.wizard.v2']) { try { localStorage.removeItem(key); sessionStorage.removeItem(key); } catch { // 프라이빗 모드 등에서 접근이 막힌다. 저장한 적이 없다는 뜻이라 지울 것도 없다. } } /** * 편집 → 서버 저장 파이프라인. * * ★ 스토어와 서로를 부르는 관계다(스토어 액션이 저장을 걸고, 저장은 스토어를 읽고 쓴다). * factSave 가 `useBuilderStore` 를 직접 import 하면 순환이 되므로, 스토어를 **인자로 넘겨** * 묶는다 — 의존이 한 방향으로만 흐른다(스토어 → factSave). * ★ 선언이 스토어보다 앞이고 대입은 뒤인 이유: 만들려면 스토어가 있어야 하는데, * 액션은 스토어가 다 만들어진 뒤에야 실행되므로 그때는 이미 채워져 있다. */ let factSaver: FactSaver; export const useBuilderStore = create((set, get) => ({ ...seedFor(FALLBACK_INDUSTRY), placeId: null, confirmedIdentity: null, pendingPick: null, savingFieldIds: [], isGathering: false, gatherStage: 1, gatherCompleted: false, selectedSectionId: null, viewport: 'pc', rightTab: 'info', isPreviewMode: false, isPublishModalOpen: false, publishedUrl: null, // 업종을 바꾸면 그 업종의 시드로 통째로 갈아탄다 — 앞 업종의 섹션·필드가 남으면 // 카페 사이트에 '객실 안내'가 붙는 식으로 섞인다. selectIndustry: (industry) => { // 화면 값이 시드로 갈아엎히므로, 아직 못 올린 편집은 갈 곳이 없다 — 여기서 버린다. factSaver.clear(); clearThemeSaves(); set((state) => { const seed = seedFor(industry); // 업종이 바뀌어도 "이 가게가 맞다"는 확인은 그대로다(같은 가게, 다른 분류일 뿐). // 다만 시드가 통째로 갈리면서 상호·주소가 예시로 되돌아가므로 여기서 다시 얹는다. const identity = state.confirmedIdentity; return { ...seed, ...(identity ? { storeName: identity.name, location: identity.address, infoFields: withConfirmedIdentity(seed.infoFields, identity), } : { /** * ★ 사장님이 친 상호·위치는 시드가 아니다 — 업종을 바꿨다고 지우면 안 된다. * 업종이 첫 화면이던 시절엔 여기가 늘 빈 값이라 티가 안 났는데, 지금은 상호를 * 먼저 받는다: 지우면 검색어를 친 뒤 업종만 골라도 그 이름이 사라진다. */ storeName: state.storeName, location: state.location, infoFields: withOwnerIdentity(seed.infoFields, state.storeName, state.location), }), // 업종을 손으로 고르면 화면의 값은 다시 시드다 — 실사업장 배선을 남겨두면 // "placeId 가 있다 = 화면 값이 서버에서 왔다"는 약속이 깨진다. placeId: null, gatherCompleted: false, gatherStage: 1, selectedSectionId: null, }; }); }, /** * 서버에서 읽은 사업장을 캔버스에 얹는다. * * 덮어쓰는 것은 신원·정보·사진뿐이다. 섹션 구성·순서·배리에이션·템플릿은 * 백엔드에 대응물이 없어 업종 시드가 계속 소유한다 — 그래서 리페치가 와도 * 사장님이 방금 옮긴 섹션 순서가 되돌아가지 않는다. * * ★ infoFields 는 서버가 소유한다. 다만 아직 서버 응답을 못 받은 편집(낙관적 반영)만은 * 리페치 위에 다시 얹는다 — 안 그러면 [맞아요] 를 누른 줄이 리페치 한 번에 * '확인 필요'로 튀어, 사장님 눈에는 클릭이 씹힌 것으로 보인다(withPendingEdits). */ applyPlace: (input, options) => { const isOnboarding = options?.isOnboarding ?? false; // 처음 여는 사업장인가. 리페치(같은 placeId)면 섹션·단계를 건드리지 않는다. const isNewPlace = get().placeId !== input.placeId; // 사업장을 갈아타면 앞 사업장의 저장 대기분은 버린다 — 들고 가면 남의 값이 이 화면에 얹힌다. // 디자인 저장 대기분도 같다: 아직 안 나간 요청이 새 사업장 id 로 나가면 남의 사이트를 덮는다. if (isNewPlace) { factSaver.clear(); clearThemeSaves(); } set((state) => { const needsReseed = isNewPlace || state.industry !== input.industry; return { // 업종이 바뀔 때만 시드를 갈아끼운다(카페 사업장에 '객실 안내'가 남지 않게). ...(needsReseed ? seedFor(input.industry) : null), ...(isNewPlace ? { /** * ★ 위저드 도중에 새로고침하면 스토어의 확정 신원이 비어 있다(브라우저에 저장하지 * 않으므로). 그대로 두면 3단계가 "아직 가게를 안 골랐다"고 판단해 수집을 열지 * 않는다 — 서버에서 읽어온 사업장으로 여기서 다시 세운다. */ ...(isOnboarding ? { confirmedIdentity: { placeId: input.placeId, name: input.storeName, address: input.location, origin: 'external' as const, sourceLabel: '네이버 플레이스', }, } : null), selectedSectionId: null, isGathering: false, } : null), /** * ★ "수집 완료"는 fact 가 있을 때만이다. * * 신원(상호·주소)은 place 의 값이지 수집물이 아니다. 이걸 구분하지 않으면 * 검색만 끝낸 화면이 2줄을 놓고 '수집 완료'라고 말하고, 사장님은 수집이 * 실패했다고 읽는다. 리페치마다 서버 기준으로 다시 정한다. */ gatherCompleted: input.hasCollected, gatherStage: input.hasCollected ? 3 : 1, placeId: input.placeId, storeName: input.storeName, location: input.location, weatherLocation: input.weatherLocation, infoFields: factSaver.withPendingEdits(input.infoFields), photos: input.photos, // seedFor 가 비워둔 자리를 서버 기준으로 채운다(순서상 위 스프레드보다 뒤여야 한다). factRefs: input.factRefs, }; }); }, // 주소창에서 placeId 가 빠지면 데모로 되돌린다. 이미 데모면 아무것도 하지 않는다 // (같은 state 를 돌려주면 구독자가 다시 그리지 않는다). clearPlace: () => { if (get().placeId === null) return; // 화면이 시드로 돌아가므로 서버로 가던 편집도 여기서 끊는다. factSaver.clear(); clearThemeSaves(); set((state) => ({...seedFor(state.industry), placeId: null})); }, // 상호·위치는 치는 즉시 정보 카드에 반영한다 — 데모에서만. 실사업장이 배선돼 있으면 // 서버가 신원의 주인이라 화면 입력이 카드를 덮지 않는다(리페치 한 번에 되돌아간다). setStoreName: (storeName) => set((state) => state.placeId !== null ? {storeName} : {storeName, infoFields: withOwnerIdentity(state.infoFields, storeName, state.location)}, ), setLocation: (location) => set((state) => state.placeId !== null ? {location} : {location, infoFields: withOwnerIdentity(state.infoFields, state.storeName, location)}, ), confirmIdentity: (identity) => set((state) => ({ confirmedIdentity: identity, storeName: identity.name, location: identity.address, // 확정된 신원은 화면의 예시 상호·주소를 즉시 밀어낸다. 출처는 사장님이 고른 그 출처다. infoFields: withConfirmedIdentity(state.infoFields, identity), })), clearIdentity: () => set({confirmedIdentity: null}), setPendingPick: (pendingPick) => set({pendingPick}), toggleChannel: (channelId) => set((state) => ({ selectedChannels: state.selectedChannels.includes(channelId) ? state.selectedChannels.filter((id) => id !== channelId) : [...state.selectedChannels, channelId], })), startGather: () => set({isGathering: true, gatherStage: 1}), setGatherStage: (gatherStage) => set({gatherStage}), finishGather: () => set({isGathering: false, gatherCompleted: true, gatherStage: 3}), /** * 템플릿을 고른다. 그 템플릿이 데리고 오는 섹션이 있으면 함께 들어온다. * * ★ 넣기만 하고 빼지 않는다. 템플릿을 눌러 보다가 넣어 둔 섹션이 사라지면 * 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다. * ★ `disabledSectionTypes` 는 이 규칙을 어기지 않는다 — **지우지 않고 끈다.** * 목록에 그대로 남아 한 번 눌러 되살린다. 시안(`/s/stay`)의 옛 항구에 예약 안내가 * 없는 것처럼, 템플릿이 "이 섹션은 안 쓴다"고 말할 자리가 없어서 켜기만 가능했다. * ★ 잠긴 섹션은 끄지 않는다. 히어로·기본 정보·오시는 길에는 SEO 필수 마크업이 달려 있어 * 사장님도 못 끄는 자리다 — 템플릿이 우회로가 되면 안 된다. * ★ 배리에이션은 **비어 있을 때만** 넣는다. 사장님이 이미 고른 모양을 템플릿이 덮으면 * 위와 같은 일이 생긴다(고른 적 없는 모양으로 돌아가 있다). */ selectTemplate: (templateId) => { set((state) => { const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId); const wanted = template?.defaultSectionTypes ?? []; const turnOff = template?.disabledSectionTypes ?? []; const variants = template?.defaultVariants ?? {}; const added = wanted .filter((type) => !state.sections.some((sec) => sec.type === type)) .map(newSectionOf) .filter((sec): sec is SectionItem => sec !== undefined); const apply = (sec: SectionItem): SectionItem => { const variantId = sec.variantId ?? variants[sec.type]; const isEnabled = wanted.includes(sec.type) ? true : turnOff.includes(sec.type) && !sec.isLocked ? false : sec.isEnabled; return {...sec, isEnabled, ...(variantId ? {variantId} : {})}; }; return { templateId, colorPaletteId: null, sections: [...state.sections.map(apply), ...added.map(apply)], }; }); persistTheme(); }, selectColorPalette: (colorPaletteId) => { set({colorPaletteId}); persistTheme(); }, // 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다. selectSection: (selectedSectionId) => set({selectedSectionId}), toggleSection: (id) => { set((state) => ({ sections: state.sections.map((sec) => // 잠긴 섹션은 끌 수 없다 — SEO·필수 마크업(히어로/기본정보/오시는 길)이 여기 달려 있다. sec.id === id && !sec.isLocked ? {...sec, isEnabled: !sec.isEnabled} : sec, ), })); persistTheme(); }, reorderSection: (fromIndex, toIndex) => { set((state) => { if ( fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= state.sections.length || toIndex >= state.sections.length ) { return state; } const next = [...state.sections]; const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); return {sections: next}; }); persistTheme(); }, updateSectionContent: (sectionId, patch) => { set((state) => ({ sections: state.sections.map((section) => section.id === sectionId ? {...section, ...patch} : section, ), })); persistTheme(); }, addSection: (type) => { const created = newSectionOf(type); if (!created) return; set((state) => { const existing = state.sections.find((sec) => sec.type === type); // 이미 있으면 새로 만들지 않는다 — 넣어 둔 JSON 이 날아간다. if (existing) { return { sections: state.sections.map((sec) => sec.id === existing.id ? {...sec, isEnabled: true} : sec, ), selectedSectionId: existing.id, }; } return {sections: [...state.sections, created], selectedSectionId: created.id}; }); persistTheme(); }, removeSection: (id) => { set((state) => ({ sections: state.sections.filter((sec) => sec.id !== id), selectedSectionId: state.selectedSectionId === id ? null : state.selectedSectionId, })); persistTheme(); }, updateSectionData: (sectionId, data) => { set((state) => ({ sections: state.sections.map((section) => section.id === sectionId ? {...section, data} : section, ), })); persistTheme(); }, // 레이아웃만 갈아끼운다 — 섹션의 내용·순서·노출 여부는 건드리지 않는다. // 그래야 사장님이 "이 모양 저 모양" 눌러보다가 편집한 걸 잃지 않는다. setSectionVariant: (sectionId, variantId) => { set((state) => ({ sections: state.sections.map((sec) => sec.id === sectionId ? {...sec, variantId} : sec, ), })); persistTheme(); }, // 전부 업종 기본 레이아웃으로. variantId 를 지우면 렌더러가 기본값으로 떨어진다. resetSectionVariants: () => { set((state) => ({ sections: state.sections.map(({variantId: _dropped, ...sec}) => sec), })); persistTheme(); }, /** * 서버에 저장된 디자인을 화면에 얹는다. * * ★ persistTheme 을 부르지 않는다. 여기는 **읽어서 얹는** 자리이므로 다시 저장하면 * 메아리가 되고, 리페치가 잦은 화면에서는 그 메아리가 계속 돈다. * * ★ 섹션은 저장값으로 통째로 갈아치우지 않고 **업종 시드 위에 얹는다.** * 저장된 항목에는 type·description 이 없다(발행 계약에 없는 값이라 안 싣는다). * 그대로 넣으면 캔버스가 섹션 타입을 몰라 아무 배리에이션도 못 고른다. * 그래서 시드에서 type·description 을 가져오고, 저장값은 순서·노출·이름·배리에이션만 준다. * * ★ 시드에 없는 저장 섹션은 버린다 — 업종이 바뀌었거나 섹션이 없어진 경우다. * 반대로 저장값에 없는 시드 섹션은 **뒤에 붙인다**: 새로 생긴 섹션이 조용히 사라지면 * 사장님은 그 기능이 있는 줄도 모른다. */ applyTheme: (theme, savedTemplateId) => { if (!theme && !savedTemplateId) return; set((state) => { const seedById = new Map(state.sections.map((sec) => [sec.id, sec])); const ordered = (theme?.sections ?? []) // ★ 반환 타입을 못 박는다. 추론에 맡기면 description 이 **필수** 키로 좁혀져 // (SectionItem 에서는 선택 키다) 아래 filter 의 타입 술어가 컴파일되지 않는다. .map((saved): SectionItem | null => { const seed = seedById.get(saved.id); // ★ 시드에 없어도 [+ 섹션 추가] 로 넣을 수 있는 것이면 되살린다. // 여기서 버리면 사장님이 추가하고 JSON 까지 채운 섹션이 새로고침 한 번에 사라진다. const revived = seed ?? (saved.type && addableSection(saved.type) ? newSectionOf(saved.type) : undefined); if (!revived) return null; seedById.delete(saved.id); return { ...revived, name: saved.name || revived.name, description: saved.description ?? revived.description, body: saved.body ?? revived.body, data: saved.data ?? revived.data, // 잠긴 섹션은 시드의 판단이 이긴다 — 서버에 꺼진 채로 저장돼 있더라도 // SEO·필수 마크업이 달린 섹션을 화면에서 꺼진 것처럼 보이게 하지 않는다. isEnabled: revived.isLocked ? true : saved.enabled, ...(saved.variantId ? {variantId: saved.variantId} : {}), }; }) .filter((sec): sec is SectionItem => sec !== null); return { /* * ★ 저장된 템플릿을 **되살린다** (2026-09-09). * 이게 없어서 에디터가 늘 업종 첫 템플릿(심플)으로 그려졌다 — 사장님이 '옛 항구'를 * 골라 발행해도, 다시 들어오면 편집 화면만 흰 바탕·고딕이었다. * 실측: 편집 캔버스 --tpl-bg #ffffff · Pretendard ↔ 발행본 #e4dac0 · Gugi. * templateId 는 theme 안이 아니라 **sites.template_id 컬럼**에 있다 * (protocol.Req_SiteTheme 주석 — theme 저장 body 에는 없다). 그래서 따로 받는다. * ★ 시드에 없는 id 는 무시한다. 옛 이름이 남아 있을 수 있고, 없는 템플릿으로 * 두면 resolveTemplate 이 첫 항목으로 떨어져 지금과 같은 상태가 된다. */ ...(savedTemplateId && INDUSTRY_CONFIGS[state.industry].templates.some((t) => t.id === savedTemplateId) ? {templateId: savedTemplateId} : {}), sections: [...ordered, ...seedById.values()], colorPaletteId: theme?.colorPaletteId ?? null, infoFields: [ ...state.infoFields, ...(theme?.customInfoFields ?? []).filter( (custom) => !state.infoFields.some((field) => field.id === custom.id), ), ], photos: theme?.visiblePhotoIds ? state.photos.map((photo) => ({ ...photo, isVisible: theme?.visiblePhotoIds?.includes(photo.id), })) : state.photos, }; }); }, updateFieldLabel: (id, label) => { set((state) => ({ infoFields: state.infoFields.map((field) => field.id === id ? {...field, label} : field), })); if (id.startsWith('custom_')) persistTheme(); }, addInfoField: (label, value) => { const id = `custom_${crypto.randomUUID()}`; set((state) => ({ infoFields: [...state.infoFields, { id, label, value, requiresVerification: false, isVerified: true, source: '직접 입력', category: 'custom', }], })); persistTheme(); }, removeInfoField: (id) => { if (!id.startsWith('custom_')) return; set((state) => ({infoFields: state.infoFields.filter((field) => field.id !== id)})); persistTheme(); }, // 정보 탭에서 직접 고친 값 = 사장님이 쓴 값이므로 그 자리에서 승인된다(백엔드 CORRECTED 대응). // // ★ 로컬 반영은 지금까지와 똑같이 그 자리에서 끝낸다 — 데모 경로는 여기까지가 전부고, // 실사업장이라도 서버 왕복을 기다리며 입력이 멈추면 안 된다(낙관적 업데이트). // 서버로 올리는 일은 queueFactSave 가 맡는다: 타이핑이라 마지막 값 하나만 보낸다. updateField: (id, value) => { const before = get().infoFields.find((field) => field.id === id); set((state) => ({ infoFields: state.infoFields.map((field) => field.id === id ? {...field, value, isVerified: true} : field, ), })); if (id.startsWith('custom_')) persistTheme(); else factSaver.queue(id, value, before); }, // 확인 탭의 [맞아요](correctedValue 없음) / [아니에요 → 수정](correctedValue 있음) 양쪽을 받는다. // // ★ 여기가 발행 게이트의 근거가 만들어지는 자리다. 로컬만 바꾸고 끝내면 게이트는 // 저장되지도 않은 검증을 보고 "발행 가능"이라고 말한다 — 그래서 반드시 서버까지 올린다. verifyField: (id, correctedValue) => { const before = get().infoFields.find((field) => field.id === id); set((state) => ({ infoFields: state.infoFields.map((field) => field.id === id ? {...field, isVerified: true, value: correctedValue ?? field.value} : field, ), })); factSaver.saveNow(id, correctedValue, before); }, setPrimaryPhoto: (photoId) => set((state) => ({ photos: state.photos.map((photo) => ({...photo, isPrimary: photo.id === photoId})), })), togglePhotoVisibility: (photoId) => { set((state) => ({ photos: state.photos.map((photo) => photo.id === photoId ? {...photo, isVisible: photo.isVisible === false} : photo, ), })); persistTheme(); }, setViewport: (viewport) => set({viewport}), setRightTab: (rightTab) => set({rightTab}), togglePreview: () => set((state) => ({isPreviewMode: !state.isPreviewMode})), openPublishModal: () => set({isPublishModalOpen: true}), closePublishModal: () => set({isPublishModalOpen: false}), setPublishedUrl: (publishedUrl) => set({publishedUrl}), reset: () => { factSaver.clear(); clearThemeSaves(); set({ ...seedFor(get().industry), placeId: null, // [처음부터]는 "이 가게가 맞다"까지 물린다 — 상호부터 다시 확인받는다. confirmedIdentity: null, pendingPick: null, isGathering: false, gatherStage: 1, gatherCompleted: false, selectedSectionId: null, isPreviewMode: false, isPublishModalOpen: false, publishedUrl: null, }); }, })); /** * 지금 상태의 템플릿(+색 팔레트 덮어쓰기)을 계산한다. 훅이 아니라 순수 함수라 * 렌더 트리 밖(스토어 액션)에서도 쓸 수 있다 — useCurrentTemplate 이 이걸 감싼다. */ factSaver = createFactSaver(useBuilderStore); function resolveTemplate(pick: { industry: IndustryType; templateId: string; colorPaletteId: string | null; }): TemplateItem { const templates = INDUSTRY_CONFIGS[pick.industry].templates; const template = templates.find((t) => t.id === pick.templateId) ?? templates[0]; const palette = COLOR_PALETTE_PRESETS.find((item) => item.id === pick.colorPaletteId); return palette ? {...template, colors: palette.colors} : template; } /** * 디자인 변경을 서버에 올린다. * * ★ 왜 각 액션 끝에서 부르나(에디터 컴포넌트의 effect 가 아니라): * effect 로 하면 서버에서 값을 **읽어와 얹는 순간에도** 저장이 한 번 돌아 메아리가 된다. * 액션은 사장님이 실제로 조작했을 때만 실행되므로 그 문제가 없다. * 대신 새 액션을 만들 때 여기 호출을 빠뜨리면 그 조작만 조용히 저장되지 않는다 — * 디자인을 바꾸는 액션은 반드시 이 줄로 끝낸다. * * ★ placeId 가 없으면(아직 가게 미확정) 저장할 사이트가 없다. queueSiteThemeSave 가 조용히 통과시킨다. */ function persistTheme() { const state = useBuilderStore.getState(); queueSiteThemeSave( state.placeId, toThemePayload( resolveTemplate(state), state.sections, state.colorPaletteId, state.infoFields, state.photos.filter((photo) => photo.isVisible !== false).map((photo) => photo.id), ), ); } /** 현재 선택된 템플릿. 없으면 업종의 첫 템플릿으로 떨어진다(빈 화면을 만들지 않는다). */ export function useCurrentTemplate(): TemplateItem { const industry = useBuilderStore((s) => s.industry); const templateId = useBuilderStore((s) => s.templateId); const colorPaletteId = useBuilderStore((s) => s.colorPaletteId); // 구독은 훅에서 하고 계산은 resolveTemplate 하나로 모은다 — 같은 식을 두 벌 두면 // 화면이 보는 색과 서버에 올라가는 색이 갈린다. return useMemo( () => resolveTemplate({industry, templateId, colorPaletteId}), [industry, templateId, colorPaletteId], ); } /** * 확인이 필요한데 아직 승인 안 된 항목. 발행 게이트와 배지 숫자가 같은 값을 본다. * * ★ filter 를 셀렉터 안에서 돌리면 안 된다 — zustand v5 는 useSyncExternalStore 를 쓰고, * 셀렉터가 매번 새 배열을 돌려주면 스냅샷이 계속 달라져 무한 렌더로 죽는다 * ("The result of getSnapshot should be cached"). 원본 배열을 구독하고 여기서 좁힌다. */ export function useUnverifiedFields(): InfoField[] { const infoFields = useBuilderStore((s) => s.infoFields); return useMemo( () => infoFields.filter((f) => f.requiresVerification && !f.isVerified), [infoFields], ); }