상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다. 사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
184 lines
8.7 KiB
TypeScript
184 lines
8.7 KiB
TypeScript
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<void> {
|
|
// placeId 가 없으면 저장할 사이트가 없다 — 네트워크를 한 번도 타지 않고 조용히 통과한다.
|
|
if (!placeId) return;
|
|
const res = await callSiteApi<ResultEnvelope>(`/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<void> = Promise.resolve();
|
|
/** 마지막 요청만 실패를 알린다 — 뒤엎힌 옛 선택의 실패까지 토스트로 쌓지 않는다. */
|
|
let saveSeq = 0;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
/**
|
|
* 저장이 **서버에 닿은 뒤** 알린다. 미리보기(iframe)가 이 신호를 듣고 다시 그린다.
|
|
*
|
|
* ★ 왜 스토어 구독이 아니라 여기인가 — 미리보기는 `GET /site/preview` 로 **서버에서**
|
|
* payload 를 받는다. 스토어가 바뀐 순간 다시 그리면 아직 저장 전이라 옛 값을 받아 오고,
|
|
* 화면은 "드래그해도 그대로" 가 된다. 저장 완료가 다시 그릴 수 있는 가장 이른 시점이다.
|
|
* ★ 실패하면 알리지 않는다 — 서버가 옛 값이면 다시 그려 봐야 옛 그림이다.
|
|
*/
|
|
type ThemeSavedListener = () => void;
|
|
const savedListeners = new Set<ThemeSavedListener>();
|
|
|
|
/** 구독 해제 함수를 돌려준다. */
|
|
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;
|
|
}
|