업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
100% 안다. 그리고 업종은 AI 를 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가
이미 들어 있고(category_group_code), 지금까지 받아 놓고 안 썼다.
- 단계가 스토어에서 주소창으로: ?step=search|industry|collect|template|generating|editor.
번호가 아니라 이름인 이유 — 단계가 4→3 으로 줄어 옛 북마크가 다른 화면을 연다
- 시작점이 상호명 검색(Step2PlaceSearch)이다. 업종 선택은 못 정했을 때의 갈래로 남는다
- 업종은 후보의 category 로 잡히고, 못 정하면 고르게 하고, 정해져도 [바꾸기] 로 바꾼다.
★ 확정 뒤 변경은 신원 확인부터 다시 받는다 — Req_UpdatePlace 에 category 가 없어
PATCH 로 못 고치고, placeAdapter 가 리페치마다 덮어써서 조용히 되돌아간다
- 랜딩 진입: ?new=1 · ?q=<상호명> · ?industry=<업종>. 한 번 읽고 replace 로 지운다
- ★ ?new=1 이 setSearchParams({}) 로 **모든 쿼리를 날리던 것**을 고쳤다 — q 가 읽히기 전에 사라졌다
- selectIndustry 가 사장님이 친 상호·위치를 지우던 것도 고쳤다(업종이 첫 화면일 땐 늘 빈 값이라 안 보였다)
- 로고는 어디서나 / 로 간다. 에디터에서는 span 이라 아예 안 눌렸다
tsc·eslint·vite build 통과
839 lines
36 KiB
TypeScript
839 lines
36 KiB
TypeScript
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;
|
|
generateStage: number;
|
|
|
|
// ── 편집 대상 데이터 ───────────────────────────────────
|
|
sections: SectionItem[];
|
|
infoFields: InfoField[];
|
|
photos: PhotoItem[];
|
|
/**
|
|
* infoFields 의 id → 서버 fact. 데모에서는 항상 비어 있다(전이시킬 fact 자체가 없다).
|
|
* 신원 줄(상호·주소·전화)도 여기 없다 — place 의 값이지 fact 가 아니다.
|
|
*/
|
|
factRefs: Record<string, FactRef>;
|
|
/** 지금 서버로 올라가는 중인 줄. 사장님에게 "저장 중"을 보여주는 근거. */
|
|
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;
|
|
/** 생성 화면에 들어가기 직전, 진행 표시를 처음으로 되돌린다. 화면 이동은 주소창이 한다. */
|
|
startGenerating: () => void;
|
|
setGenerateStage: (stage: number) => 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<SectionItem, 'name' | 'description' | 'body'>) => void;
|
|
/** 붙여넣기 아이템의 원문 JSON. 깨져 있어도 그대로 담는다 — 판단은 렌더러가 한다. */
|
|
updateSectionData: (sectionId: string, data: string) => void;
|
|
setSectionVariant: (sectionId: string, variantId: string) => void;
|
|
resetSectionVariants: () => void;
|
|
/** 서버에 저장돼 있던 디자인을 화면에 얹는다(새로고침·다른 기기 복원). */
|
|
applyTheme: (theme: SiteThemePayload | null | undefined) => 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<string, string | undefined> = {
|
|
[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<string, FactRef>,
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* ★ 위저드 상태를 브라우저에 저장하지 않는다.
|
|
*
|
|
* 한때 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<BuilderState>((set, get) => ({
|
|
...seedFor(FALLBACK_INDUSTRY),
|
|
placeId: null,
|
|
confirmedIdentity: null,
|
|
pendingPick: null,
|
|
savingFieldIds: [],
|
|
|
|
isGathering: false,
|
|
gatherStage: 1,
|
|
gatherCompleted: false,
|
|
generateStage: 1,
|
|
|
|
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}),
|
|
|
|
/**
|
|
* 템플릿을 고른다. 그 템플릿이 데리고 오는 섹션이 있으면 함께 들어온다.
|
|
*
|
|
* ★ 넣기만 하고 빼지 않는다. 템플릿을 눌러 보다가 넣어 둔 섹션이 사라지면
|
|
* 사장님은 그게 템플릿 때문인 줄 모르고 자기가 지웠다고 생각한다.
|
|
*/
|
|
selectTemplate: (templateId) => {
|
|
set((state) => {
|
|
const template = INDUSTRY_CONFIGS[state.industry].templates.find((t) => t.id === templateId);
|
|
const wanted = template?.defaultSectionTypes ?? [];
|
|
const added = wanted
|
|
.filter((type) => !state.sections.some((sec) => sec.type === type))
|
|
.map(newSectionOf)
|
|
.filter((sec): sec is SectionItem => sec !== undefined);
|
|
return {
|
|
templateId,
|
|
colorPaletteId: null,
|
|
sections: [
|
|
...state.sections.map((sec) =>
|
|
wanted.includes(sec.type) ? {...sec, isEnabled: true} : sec,
|
|
),
|
|
...added,
|
|
],
|
|
};
|
|
});
|
|
persistTheme();
|
|
},
|
|
selectColorPalette: (colorPaletteId) => {
|
|
set({colorPaletteId});
|
|
persistTheme();
|
|
},
|
|
// 화면 이동은 부르는 쪽이 주소창으로 한다(`?step=generating`) — 여기서는 진행 표시만 되감는다.
|
|
startGenerating: () => set({generateStage: 1}),
|
|
setGenerateStage: (generateStage) => set({generateStage}),
|
|
|
|
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) => {
|
|
if (!theme) 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 {
|
|
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,
|
|
generateStage: 1,
|
|
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],
|
|
);
|
|
}
|