import type {InfoField, SectionItem, TemplateItem, TemplateLook} from '@o2o/shared'; import {ApiError} from '@/api'; import {notifyApiError} from '@/lib/notify'; import {callSiteApi, type ResultEnvelope} from './siteApi'; /** * 에디터 [디자인] 탭의 결과를 서버에 저장한다(POST /v1/place/{id}/site/theme). * * ★ 왜 필요한가 * 사장님이 에디터에서 바꿀 수 있는 디자인은 여섯 가지다 — 섹션 on/off · 섹션 순서 · * 섹션별 배리에이션 · 색 · 서체 · 템플릿. 이 중 저장되는 자리가 있던 건 templateId 하나뿐이었고, * 나머지 다섯은 zustand 메모리에만 있다가 **새로고침 한 번에 사라졌다.** * 발행 잡(backend services/site_payload)도 읽을 곳이 없어 업종 기본 표(_DEFAULT_THEME)로 구웠고, * 그 표는 `enabled` 를 항상 True 로 박아 넣었다 — 사장님이 섹션을 꺼도 발행본에는 그대로 나왔다. * "미리보기"가 미리보기가 아니었던 자리가 여기다. * * ★ 서버는 이 값을 해석하지 않는다. * 섹션 목록·배리에이션 키·색 팔레트는 전부 프론트(레지스트리·프리셋)가 소유한다. * 서버가 화이트리스트를 들면 프론트에 항목 하나 늘 때마다 백엔드를 같이 고쳐야 한다. * 모르는 값이 들어가도 발행 잡이 업종 기본으로 떨어뜨리므로 화면이 깨지지 않는다. * * ★ templateId 는 여기 넣지 않는다 — 이미 sites.template_id 라는 자기 자리가 있다 * (siteTemplate.ts). 같은 값을 두 곳에 두면 어느 쪽이 진실인지 물어야 한다. * * ★ orval 을 쓰지 않는 이유는 siteApi.ts 참조. */ export interface SiteThemePayload { /** * 해석된 색. 발행 렌더러는 이 값만 보면 된다 — 팔레트 프리셋 목록을 몰라도 된다. */ colors: TemplateItem['colors']; fontStyle: string; /** * 사장님이 고른 색 팔레트 프리셋 id. * * ★ 해석된 colors 만 저장하면 에디터로 되돌릴 때 "어느 프리셋을 골랐었는지"를 잃는다. * 색값을 프리셋 목록과 역으로 대조해 맞추는 방법도 있지만, 두 프리셋의 색이 겹치거나 * 프리셋 색이 나중에 한 톤 바뀌면 엉뚱한 칩이 선택된 것처럼 보인다. * 렌더러가 쓸 값(colors)과 에디터가 복원할 값(id)은 쓰임이 다르니 둘 다 싣는다. * 서버는 어느 쪽도 해석하지 않는다. */ colorPaletteId?: string | null; /** * 템플릿의 생김새(서체·모서리·테두리·그림자·여백). * * ★ 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다 — * 사장님이 레트로를 골라도 발행 페이지만 고딕이었다. 서버는 해석하지 않고 보관만 한다. */ look?: TemplateLook; sections: { id: string; /** * 섹션 타입. * * ★ 예전엔 안 실었다 — 시드에 다 있으니 id 로 찾으면 됐다. [+ 섹션 추가] 가 생기면서 * 시드에 없는 섹션이 저장되기 시작했고, type 이 없으면 복원 때 그게 뭐였는지 알 길이 없어 * 통째로 버려진다(사장님이 채운 JSON 까지 같이). */ type?: string; name: string; enabled: boolean; locked: boolean; variantId?: string; description?: string; body?: string; /** 붙여넣기 아이템의 원문 JSON. 서버는 이 값도 해석하지 않고 그대로 보관한다. */ data?: string; }[]; customInfoFields?: InfoField[]; visiblePhotoIds?: string[]; } /** * 에디터 상태 → 저장 모양. * * ★ 배열 순서가 곧 섹션 순서다. 순서를 따로 필드로 두지 않는 이유는, 번호를 들고 다니면 * 드래그 한 번에 전 항목의 번호를 다시 매겨야 하고 그러다 한 칸이 어긋나면 조용히 뒤집힌다. * ★ 화면 타입(SectionItem)의 isEnabled/isLocked 를 발행 계약(SectionSetting)의 enabled/locked 로 * 여기서 한 번만 바꾼다 — 두 층의 이름이 다른 건 계약이 다르기 때문이고, 그 번역은 경계에 둔다. */ export function toThemePayload( template: TemplateItem, sections: SectionItem[], colorPaletteId: string | null, infoFields: InfoField[] = [], visiblePhotoIds: string[] = [], ): SiteThemePayload { return { colors: template.colors, fontStyle: template.fontStyle, look: template.look, colorPaletteId, sections: sections.map((s) => ({ id: s.id, type: s.type, name: s.name, enabled: s.isEnabled, locked: s.isLocked, ...(s.description ? {description: s.description} : {}), ...(s.body ? {body: s.body} : {}), ...(s.data ? {data: s.data} : {}), ...(s.variantId ? {variantId: s.variantId} : {}), })), customInfoFields: infoFields.filter((field) => field.id.startsWith('custom_')), visiblePhotoIds, }; } export async function saveSiteTheme(placeId: string, theme: SiteThemePayload): Promise { // placeId 가 없으면 저장할 사이트가 없다 — 네트워크를 한 번도 타지 않고 조용히 통과한다. if (!placeId) return; const res = await callSiteApi(`/v1/place/${placeId}/site/theme`, { method: 'POST', body: JSON.stringify({theme}), }); // ★ 거절도 200 으로 온다. 여기서 throw 하지 않으면 저장 안 된 디자인이 저장된 것처럼 넘어가고, // 사장님은 발행하고 나서야 자기 설정이 없어진 걸 안다. if (res.result?.success === false) throw new ApiError(200, 'SITE_THEME_REJECTED', res); } /** * 저장 대기줄 + 디바운스. * * ★ 디자인 편집은 연속으로 들어온다 — 드래그로 섹션을 옮기면 한 번에 여러 번, * 배리에이션은 "이 모양 저 모양" 눌러보며 여러 번. 매 조작마다 쏘면 서버를 두드리는 것도 문제지만, * 병렬로 나간 요청이 순서를 바꿔 도착하면 **마지막 선택이 옛 선택에 덮인다** — 화면은 새 디자인, * 서버는 옛 디자인이 되고 그 차이는 발행하고 나서야 드러난다. * 그래서 (1) 멈추면 마지막 것 하나만 보내고 (2) 보낸 것들은 순서대로 흘린다. */ const THEME_SAVE_DEBOUNCE_MS = 600; let saveChain: Promise = Promise.resolve(); /** 마지막 요청만 실패를 알린다 — 뒤엎힌 옛 선택의 실패까지 토스트로 쌓지 않는다. */ let saveSeq = 0; let timer: ReturnType | undefined; /** * 저장이 **서버에 닿은 뒤** 알린다. 미리보기(iframe)가 이 신호를 듣고 다시 그린다. * * ★ 왜 스토어 구독이 아니라 여기인가 — 미리보기는 `GET /site/preview` 로 **서버에서** * payload 를 받는다. 스토어가 바뀐 순간 다시 그리면 아직 저장 전이라 옛 값을 받아 오고, * 화면은 "드래그해도 그대로" 가 된다. 저장 완료가 다시 그릴 수 있는 가장 이른 시점이다. * ★ 실패하면 알리지 않는다 — 서버가 옛 값이면 다시 그려 봐야 옛 그림이다. */ type ThemeSavedListener = () => void; const savedListeners = new Set(); /** 구독 해제 함수를 돌려준다. */ export function onSiteThemeSaved(listener: ThemeSavedListener): () => void { savedListeners.add(listener); return () => savedListeners.delete(listener); } /** * 디자인 변경을 저장하되 **화면을 기다리게 하지 않는다.** * * 저장 실패가 편집을 막으면 안 된다 — 디자인은 주소와 달리 발행 뒤에도 바꿀 수 있는 값이라 * 여기서 사장님을 세울 이유가 없다. 알리기만 한다. */ export function queueSiteThemeSave(placeId: string | null, theme: SiteThemePayload) { if (!placeId) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { saveSeq += 1; const seq = saveSeq; saveChain = saveChain.then(async () => { try { await saveSiteTheme(placeId, theme); // 뒤에 올라탄 저장이 있으면 그것이 끝날 때 알린다 — 중간 상태로 두 번 그리지 않는다. if (seq === saveSeq) savedListeners.forEach((listener) => listener()); } catch (error) { if (seq !== saveSeq) return; // 이미 다른 편집이 올라탔다 — 그쪽 결과만 알린다. notifyApiError(error, '디자인 설정을 저장하지 못했습니다. 발행 전에 다시 확인해 주세요.'); } }); }, THEME_SAVE_DEBOUNCE_MS); } /** 화면이 다른 사업장으로 갈아탄다 — 갈 곳 없는 저장 대기분을 버린다. */ export function clearThemeSaves() { if (timer) clearTimeout(timer); timer = undefined; }