@leviosa-ai/detail-page-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상세페이지 생성기의 상품 소스(플랫폼) 추상화.
|
|
3
|
+
*
|
|
4
|
+
* 생성기는 원래 카페24 Admin API만 호출했다. 셀러가 연동한 플랫폼은 카페24 외에도
|
|
5
|
+
* 네이버·쿠팡이 있으므로, 어떤 플랫폼에서 상품을 가져올지 먼저 고르고 그 플랫폼의
|
|
6
|
+
* API로 목록/상세를 조회한다. 이 모듈은 플랫폼별 응답을 생성기가 쓰는 하나의
|
|
7
|
+
* 스냅샷 형태로 정규화한다.
|
|
8
|
+
*
|
|
9
|
+
* 보조 정보(옵션·카테고리) 조회가 실패해도 상세 조회가 성공했다면 프리필을 진행한다.
|
|
10
|
+
* 예전에는 `Promise.all` 이라 옵션 하나가 500이면 프리필 전체가 버려졌다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Cafe24Flag,
|
|
15
|
+
DetailPageHost,
|
|
16
|
+
} from "../../components/detail-page/detail-page-host-context";
|
|
17
|
+
|
|
18
|
+
export const DETAIL_PAGE_PRODUCT_SOURCES = [
|
|
19
|
+
"cafe24",
|
|
20
|
+
"naver",
|
|
21
|
+
"coupang",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export type DetailPageProductSource =
|
|
25
|
+
(typeof DETAIL_PAGE_PRODUCT_SOURCES)[number];
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_DETAIL_PAGE_PRODUCT_SOURCE: DetailPageProductSource =
|
|
28
|
+
"cafe24";
|
|
29
|
+
|
|
30
|
+
/** 상품 목록/상세 API가 아직 없어 선택지로만 노출하는 플랫폼. */
|
|
31
|
+
export const DETAIL_PAGE_UNSUPPORTED_SOURCES = ["amazon"] as const;
|
|
32
|
+
|
|
33
|
+
export type DetailPageUnsupportedSource =
|
|
34
|
+
(typeof DETAIL_PAGE_UNSUPPORTED_SOURCES)[number];
|
|
35
|
+
|
|
36
|
+
export function parseDetailPageProductSource(
|
|
37
|
+
value: string | null | undefined,
|
|
38
|
+
): DetailPageProductSource {
|
|
39
|
+
const normalized = (value ?? "").trim().toLowerCase();
|
|
40
|
+
return (
|
|
41
|
+
DETAIL_PAGE_PRODUCT_SOURCES.find((source) => source === normalized) ??
|
|
42
|
+
DEFAULT_DETAIL_PAGE_PRODUCT_SOURCE
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 목록 화면에 필요한 최소 상품 정보. */
|
|
47
|
+
export interface DetailPageProductListItem {
|
|
48
|
+
source: DetailPageProductSource;
|
|
49
|
+
/** 플랫폼별 상품 식별자. 쿠팡은 숫자가 아닐 수 있어 문자열로 다룬다. */
|
|
50
|
+
productNo: string;
|
|
51
|
+
name: string;
|
|
52
|
+
imageUrl: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DetailPageProductListPage {
|
|
56
|
+
items: DetailPageProductListItem[];
|
|
57
|
+
/** 다음 페이지 요청에 쓸 커서. 없으면 마지막 페이지. */
|
|
58
|
+
nextCursor: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DetailPageProductListParams {
|
|
62
|
+
/** 1-based 페이지 번호 또는 커서. */
|
|
63
|
+
cursor?: string | null;
|
|
64
|
+
pageSize: number;
|
|
65
|
+
query?: string;
|
|
66
|
+
categoryName?: string;
|
|
67
|
+
display?: Cafe24Flag;
|
|
68
|
+
selling?: Cafe24Flag;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 프리필 도중 실패했지만 진행을 막지 않은 보조 조회. */
|
|
73
|
+
export interface DetailPageProductDegradation {
|
|
74
|
+
field: "options" | "categories";
|
|
75
|
+
message: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 플랫폼 응답을 생성기가 소비하는 하나의 형태로 정규화한 결과.
|
|
80
|
+
*
|
|
81
|
+
* 카페24 경로는 기존 동작을 그대로 유지한다(필드 우선순위 포함).
|
|
82
|
+
*/
|
|
83
|
+
export interface DetailPageProductSnapshot {
|
|
84
|
+
source: DetailPageProductSource;
|
|
85
|
+
productNo: string;
|
|
86
|
+
productName: string;
|
|
87
|
+
summaryDescription: string;
|
|
88
|
+
simpleDescription: string;
|
|
89
|
+
detailContent: string;
|
|
90
|
+
categories: string[];
|
|
91
|
+
options: string[];
|
|
92
|
+
representativeImageUrl: string;
|
|
93
|
+
specText: string;
|
|
94
|
+
shippingText: string;
|
|
95
|
+
productInfoVersion: string;
|
|
96
|
+
/** 백엔드가 계산해 준 소스 이미지 목록(있는 플랫폼만). */
|
|
97
|
+
backendSourceImageUrls: string[];
|
|
98
|
+
/** 백엔드 추천 검색 키워드 후보(우선순위 순). */
|
|
99
|
+
backendKeywordCandidates: unknown[];
|
|
100
|
+
/** 백엔드가 만든 규격/배송 텍스트. */
|
|
101
|
+
backendSpecShipping: string;
|
|
102
|
+
/**
|
|
103
|
+
* 백엔드 톤 추천이 담긴 레코드들. 생성기의 `pickBackendDesignTone(...sources)` 에
|
|
104
|
+
* 그대로 펼쳐 넘긴다.
|
|
105
|
+
*/
|
|
106
|
+
backendToneSources: Array<Record<string, unknown> | null>;
|
|
107
|
+
degraded: DetailPageProductDegradation[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface DetailPageSnapshotParams {
|
|
111
|
+
forceRefresh?: boolean;
|
|
112
|
+
knownProductInfoVersion?: string;
|
|
113
|
+
signal?: AbortSignal;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
117
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
118
|
+
? (value as Record<string, unknown>)
|
|
119
|
+
: null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function asArray(value: unknown): unknown[] {
|
|
123
|
+
return Array.isArray(value) ? value : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function firstString(...values: unknown[]): string {
|
|
127
|
+
for (const value of values) {
|
|
128
|
+
if (typeof value !== "string") continue;
|
|
129
|
+
const normalized = value.trim();
|
|
130
|
+
if (normalized) return normalized;
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function errorMessage(error: unknown): string {
|
|
136
|
+
if (error instanceof Error) return error.message;
|
|
137
|
+
return String(error ?? "unknown error");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizeCategoryText(category: unknown): string {
|
|
141
|
+
const record = asRecord(category);
|
|
142
|
+
if (!record) return firstString(category);
|
|
143
|
+
return firstString(
|
|
144
|
+
record.full_category_name,
|
|
145
|
+
record.fullCategoryName,
|
|
146
|
+
record.category_name,
|
|
147
|
+
record.categoryName,
|
|
148
|
+
record.name,
|
|
149
|
+
record.path,
|
|
150
|
+
record.category_no != null ? String(record.category_no) : "",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── 카페24 ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
async function listCafe24Products(
|
|
157
|
+
host: DetailPageHost,
|
|
158
|
+
params: DetailPageProductListParams,
|
|
159
|
+
): Promise<DetailPageProductListPage> {
|
|
160
|
+
const page = Number(params.cursor ?? "1") || 1;
|
|
161
|
+
const response = await host.api.fetchCafe24Products({
|
|
162
|
+
page,
|
|
163
|
+
page_size: params.pageSize,
|
|
164
|
+
query: params.query || undefined,
|
|
165
|
+
category_name: params.categoryName || undefined,
|
|
166
|
+
include_sub_category: params.categoryName ? true : undefined,
|
|
167
|
+
display: params.display,
|
|
168
|
+
selling: params.selling,
|
|
169
|
+
include_details: false,
|
|
170
|
+
});
|
|
171
|
+
const items = response.items.map((product) => ({
|
|
172
|
+
source: "cafe24" as const,
|
|
173
|
+
productNo: String(host.product.getCafe24ProductNo(product) ?? ""),
|
|
174
|
+
name:
|
|
175
|
+
product.product_name ||
|
|
176
|
+
product.name ||
|
|
177
|
+
`#${host.product.getCafe24ProductNo(product) ?? ""}`,
|
|
178
|
+
imageUrl: firstString(
|
|
179
|
+
product.list_image,
|
|
180
|
+
product.tiny_image,
|
|
181
|
+
product.small_image,
|
|
182
|
+
product.detail_image,
|
|
183
|
+
),
|
|
184
|
+
}));
|
|
185
|
+
const hasMore = page * response.page_size < response.total;
|
|
186
|
+
return { items, nextCursor: hasMore ? String(page + 1) : null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function loadCafe24Snapshot(
|
|
190
|
+
host: DetailPageHost,
|
|
191
|
+
productNo: string,
|
|
192
|
+
params: DetailPageSnapshotParams,
|
|
193
|
+
): Promise<DetailPageProductSnapshot> {
|
|
194
|
+
const numericProductNo = Number(productNo);
|
|
195
|
+
const refreshParams = {
|
|
196
|
+
force_refresh: Boolean(params.forceRefresh),
|
|
197
|
+
known_product_info_version: params.knownProductInfoVersion || undefined,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// 상세는 필수, 옵션/카테고리는 보조. 보조 조회가 죽어도 프리필은 살린다.
|
|
201
|
+
const [detail, optionsResult, categoriesResult] = await Promise.all([
|
|
202
|
+
host.api.fetchCafe24ProductDetail(numericProductNo, params.signal, refreshParams),
|
|
203
|
+
settled(
|
|
204
|
+
host.api.fetchCafe24ProductOptions(numericProductNo, 1, params.signal, refreshParams),
|
|
205
|
+
),
|
|
206
|
+
settled(
|
|
207
|
+
host.api.fetchCafe24ProductCategories(
|
|
208
|
+
numericProductNo,
|
|
209
|
+
1,
|
|
210
|
+
params.signal,
|
|
211
|
+
refreshParams,
|
|
212
|
+
),
|
|
213
|
+
),
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
const degraded: DetailPageProductDegradation[] = [];
|
|
217
|
+
if (optionsResult.error) {
|
|
218
|
+
degraded.push({ field: "options", message: errorMessage(optionsResult.error) });
|
|
219
|
+
}
|
|
220
|
+
if (categoriesResult.error) {
|
|
221
|
+
degraded.push({
|
|
222
|
+
field: "categories",
|
|
223
|
+
message: errorMessage(categoriesResult.error),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const detailRecord = detail as unknown as Record<string, unknown>;
|
|
228
|
+
const rawRecord = asRecord(detail._cafe24_raw) ?? {};
|
|
229
|
+
const origin = detail.originProduct;
|
|
230
|
+
const originRecord = asRecord(origin) ?? {};
|
|
231
|
+
const enrichment = asRecord(detailRecord.detailPage) ?? asRecord(rawRecord.detailPage);
|
|
232
|
+
|
|
233
|
+
const options = host.product.extractCafe24ProductOptions(optionsResult.value).map(
|
|
234
|
+
(option) => {
|
|
235
|
+
const optionRecord = asRecord(option);
|
|
236
|
+
if (!optionRecord) return "";
|
|
237
|
+
const optionName = firstString(optionRecord.option_name, optionRecord.name);
|
|
238
|
+
const optionValues = asArray(
|
|
239
|
+
optionRecord.option_value ??
|
|
240
|
+
optionRecord.option_values ??
|
|
241
|
+
optionRecord.values,
|
|
242
|
+
)
|
|
243
|
+
.map((item) => {
|
|
244
|
+
const itemRecord = asRecord(item);
|
|
245
|
+
if (!itemRecord) return firstString(item);
|
|
246
|
+
return firstString(
|
|
247
|
+
itemRecord.option_text,
|
|
248
|
+
itemRecord.option_value,
|
|
249
|
+
itemRecord.value,
|
|
250
|
+
itemRecord.name,
|
|
251
|
+
);
|
|
252
|
+
})
|
|
253
|
+
.filter(Boolean);
|
|
254
|
+
if (!optionName && optionValues.length === 0) return "";
|
|
255
|
+
const optionText = `${optionName || "option"}: ${optionValues.join(", ")}`;
|
|
256
|
+
if (/개인정보/.test(optionText)) return "";
|
|
257
|
+
return optionText;
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
source: "cafe24",
|
|
263
|
+
productNo,
|
|
264
|
+
productName: firstString(
|
|
265
|
+
origin?.name,
|
|
266
|
+
rawRecord.product_name,
|
|
267
|
+
rawRecord.name,
|
|
268
|
+
),
|
|
269
|
+
summaryDescription: firstString(
|
|
270
|
+
origin?.summary_description,
|
|
271
|
+
originRecord.summary_description,
|
|
272
|
+
rawRecord.summary_description,
|
|
273
|
+
),
|
|
274
|
+
simpleDescription: firstString(
|
|
275
|
+
originRecord.simple_summary,
|
|
276
|
+
rawRecord.simple_summary,
|
|
277
|
+
origin?.simple_description,
|
|
278
|
+
originRecord.simple_description,
|
|
279
|
+
rawRecord.simple_description,
|
|
280
|
+
),
|
|
281
|
+
detailContent: firstString(
|
|
282
|
+
origin?.detailContent,
|
|
283
|
+
rawRecord.description,
|
|
284
|
+
rawRecord.mobile_description,
|
|
285
|
+
),
|
|
286
|
+
categories: host.product.extractCafe24ProductCategories(categoriesResult.value).map(
|
|
287
|
+
normalizeCategoryText,
|
|
288
|
+
),
|
|
289
|
+
options: options.filter(Boolean),
|
|
290
|
+
representativeImageUrl: firstString(
|
|
291
|
+
origin?.images?.representativeImage?.url,
|
|
292
|
+
),
|
|
293
|
+
specText: firstString(
|
|
294
|
+
rawRecord.specification,
|
|
295
|
+
rawRecord.spec,
|
|
296
|
+
rawRecord.additional_information,
|
|
297
|
+
rawRecord.additional_info,
|
|
298
|
+
),
|
|
299
|
+
shippingText: firstString(
|
|
300
|
+
rawRecord.shipping_info,
|
|
301
|
+
rawRecord.shipping,
|
|
302
|
+
rawRecord.delivery_info,
|
|
303
|
+
rawRecord.delivery,
|
|
304
|
+
),
|
|
305
|
+
productInfoVersion: firstString(
|
|
306
|
+
rawRecord.product_info_version,
|
|
307
|
+
detailRecord.product_info_version,
|
|
308
|
+
rawRecord.productInfoVersion,
|
|
309
|
+
detailRecord.productInfoVersion,
|
|
310
|
+
rawRecord.version,
|
|
311
|
+
detailRecord.version,
|
|
312
|
+
params.knownProductInfoVersion,
|
|
313
|
+
"unversioned",
|
|
314
|
+
),
|
|
315
|
+
backendSourceImageUrls: [
|
|
316
|
+
...asArray(enrichment?.source_image_urls),
|
|
317
|
+
...asArray(rawRecord.source_image_urls),
|
|
318
|
+
...asArray(detailRecord.source_image_urls),
|
|
319
|
+
]
|
|
320
|
+
.map((item) => firstString(item))
|
|
321
|
+
.filter(Boolean),
|
|
322
|
+
backendKeywordCandidates: [
|
|
323
|
+
enrichment?.search_keyword,
|
|
324
|
+
enrichment?.suggested_search_keyword,
|
|
325
|
+
enrichment?.pandarank_search_keyword,
|
|
326
|
+
rawRecord.search_keyword,
|
|
327
|
+
detailRecord.search_keyword,
|
|
328
|
+
rawRecord.pandarank_search_keyword,
|
|
329
|
+
detailRecord.pandarank_search_keyword,
|
|
330
|
+
rawRecord.suggested_search_keyword,
|
|
331
|
+
detailRecord.suggested_search_keyword,
|
|
332
|
+
],
|
|
333
|
+
backendSpecShipping: firstString(
|
|
334
|
+
enrichment?.spec_shipping,
|
|
335
|
+
rawRecord.spec_shipping,
|
|
336
|
+
detailRecord.spec_shipping,
|
|
337
|
+
),
|
|
338
|
+
// detailPage 를 먼저 본다. 백엔드 톤 추천은 이 블록 아래에만 들어 있어
|
|
339
|
+
// 최상위만 훑던 예전 코드에서는 한 번도 읽히지 않았다.
|
|
340
|
+
backendToneSources: [enrichment, rawRecord, detailRecord],
|
|
341
|
+
degraded,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── 네이버 ────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
async function listNaverProducts(
|
|
348
|
+
host: DetailPageHost,
|
|
349
|
+
params: DetailPageProductListParams,
|
|
350
|
+
): Promise<DetailPageProductListPage> {
|
|
351
|
+
const page = Number(params.cursor ?? "1") || 1;
|
|
352
|
+
const response = await host.api.fetchNaverProducts(
|
|
353
|
+
{ page, size: params.pageSize },
|
|
354
|
+
params.signal,
|
|
355
|
+
);
|
|
356
|
+
const keyword = (params.query ?? "").trim().toLowerCase();
|
|
357
|
+
const items = response.contents
|
|
358
|
+
.map((product) => {
|
|
359
|
+
const channel = product.channelProducts?.[0];
|
|
360
|
+
return {
|
|
361
|
+
source: "naver" as const,
|
|
362
|
+
productNo: String(product.originProductNo),
|
|
363
|
+
name: channel?.name || `#${product.originProductNo}`,
|
|
364
|
+
imageUrl: firstString(channel?.representativeImage?.url),
|
|
365
|
+
};
|
|
366
|
+
})
|
|
367
|
+
// 네이버 목록 API는 이름 검색 파라미터를 노출하지 않아 클라이언트에서 거른다.
|
|
368
|
+
.filter((item) => !keyword || item.name.toLowerCase().includes(keyword));
|
|
369
|
+
const hasMore = page < (response.totalPages ?? 0);
|
|
370
|
+
return { items, nextCursor: hasMore ? String(page + 1) : null };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function loadNaverSnapshot(
|
|
374
|
+
host: DetailPageHost,
|
|
375
|
+
productNo: string,
|
|
376
|
+
params: DetailPageSnapshotParams,
|
|
377
|
+
): Promise<DetailPageProductSnapshot> {
|
|
378
|
+
const detail = await host.api.fetchNaverProductDetail(Number(productNo), params.signal);
|
|
379
|
+
const origin = detail.originProduct;
|
|
380
|
+
const detailRecord = detail as unknown as Record<string, unknown>;
|
|
381
|
+
const enrichment = asRecord(detailRecord.detailPage);
|
|
382
|
+
|
|
383
|
+
const optionGroups = new Map<string, string[]>();
|
|
384
|
+
for (const option of origin?.detailAttribute?.optionInfo?.optionSimple ?? []) {
|
|
385
|
+
const groupName = firstString(option.groupName) || "option";
|
|
386
|
+
const values = optionGroups.get(groupName) ?? [];
|
|
387
|
+
const name = firstString(option.name);
|
|
388
|
+
if (name && !values.includes(name)) values.push(name);
|
|
389
|
+
optionGroups.set(groupName, values);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const deliveryInfo = origin?.deliveryInfo;
|
|
393
|
+
const shippingText = [
|
|
394
|
+
deliveryInfo?.deliveryCompany
|
|
395
|
+
? `배송 업체: ${deliveryInfo.deliveryCompany}`
|
|
396
|
+
: "",
|
|
397
|
+
deliveryInfo?.deliveryFee?.baseFee != null
|
|
398
|
+
? `배송비: ${deliveryInfo.deliveryFee.baseFee}`
|
|
399
|
+
: "",
|
|
400
|
+
deliveryInfo?.claimDeliveryInfo?.returnDeliveryFee != null
|
|
401
|
+
? `반품 배송비: ${deliveryInfo.claimDeliveryInfo.returnDeliveryFee}`
|
|
402
|
+
: "",
|
|
403
|
+
]
|
|
404
|
+
.filter(Boolean)
|
|
405
|
+
.join("\n");
|
|
406
|
+
|
|
407
|
+
const originArea = origin?.detailAttribute?.originAreaInfo?.content;
|
|
408
|
+
const brandName = origin?.detailAttribute?.naverShoppingSearchInfo?.brandName;
|
|
409
|
+
const specText = [
|
|
410
|
+
brandName ? `브랜드: ${brandName}` : "",
|
|
411
|
+
originArea ? `원산지: ${originArea}` : "",
|
|
412
|
+
]
|
|
413
|
+
.filter(Boolean)
|
|
414
|
+
.join("\n");
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
source: "naver",
|
|
418
|
+
productNo,
|
|
419
|
+
productName: firstString(origin?.name),
|
|
420
|
+
// CS_OPT 를 거친 상품은 소싱 서버가 detailContent 하단에 실어둔 요약을
|
|
421
|
+
// 되읽어 detailPage 로 돌려준다. 미적용 상품은 빈 문자열 → 원본 폴백.
|
|
422
|
+
summaryDescription: firstString(enrichment?.summary_description),
|
|
423
|
+
simpleDescription: firstString(enrichment?.simple_description),
|
|
424
|
+
detailContent: firstString(origin?.detailContent),
|
|
425
|
+
categories: [firstString(origin?.leafCategoryId)].filter(Boolean),
|
|
426
|
+
options: [...optionGroups.entries()]
|
|
427
|
+
.map(([groupName, values]) =>
|
|
428
|
+
values.length ? `${groupName}: ${values.join(", ")}` : "",
|
|
429
|
+
)
|
|
430
|
+
.filter(Boolean),
|
|
431
|
+
representativeImageUrl: firstString(
|
|
432
|
+
origin?.images?.representativeImage?.url,
|
|
433
|
+
),
|
|
434
|
+
specText,
|
|
435
|
+
shippingText,
|
|
436
|
+
productInfoVersion: firstString(
|
|
437
|
+
detailRecord.product_info_version,
|
|
438
|
+
params.knownProductInfoVersion,
|
|
439
|
+
"unversioned",
|
|
440
|
+
),
|
|
441
|
+
backendSourceImageUrls: [
|
|
442
|
+
...asArray(enrichment?.source_image_urls),
|
|
443
|
+
...(origin?.images?.optionalImages ?? []).map((image) =>
|
|
444
|
+
firstString(image?.url),
|
|
445
|
+
),
|
|
446
|
+
].filter(Boolean) as string[],
|
|
447
|
+
backendKeywordCandidates: [
|
|
448
|
+
enrichment?.search_keyword,
|
|
449
|
+
enrichment?.suggested_search_keyword,
|
|
450
|
+
...(origin?.detailAttribute?.seoInfo?.sellerTags ?? []).map(
|
|
451
|
+
(tag) => tag.text,
|
|
452
|
+
),
|
|
453
|
+
],
|
|
454
|
+
backendSpecShipping: firstString(enrichment?.spec_shipping),
|
|
455
|
+
backendToneSources: [enrichment],
|
|
456
|
+
degraded: [],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── 쿠팡 ──────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
async function listCoupangProducts(
|
|
463
|
+
host: DetailPageHost,
|
|
464
|
+
params: DetailPageProductListParams,
|
|
465
|
+
): Promise<DetailPageProductListPage> {
|
|
466
|
+
const response = await host.product.coupangApi.listListings({
|
|
467
|
+
nextToken: params.cursor || undefined,
|
|
468
|
+
maxPerPage: params.pageSize,
|
|
469
|
+
name: params.query || undefined,
|
|
470
|
+
signal: params.signal,
|
|
471
|
+
});
|
|
472
|
+
return {
|
|
473
|
+
items: response.data.map((listing) => ({
|
|
474
|
+
source: "coupang" as const,
|
|
475
|
+
productNo: String(listing.sellerProductId),
|
|
476
|
+
name: listing.sellerProductName || `#${listing.sellerProductId}`,
|
|
477
|
+
imageUrl: firstString(listing.representativeImage),
|
|
478
|
+
})),
|
|
479
|
+
nextCursor: response.nextToken || null,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function loadCoupangSnapshot(
|
|
484
|
+
host: DetailPageHost,
|
|
485
|
+
productNo: string,
|
|
486
|
+
params: DetailPageSnapshotParams,
|
|
487
|
+
): Promise<DetailPageProductSnapshot> {
|
|
488
|
+
const response = await host.product.coupangApi.getListing(productNo, {
|
|
489
|
+
signal: params.signal,
|
|
490
|
+
});
|
|
491
|
+
const listing = response.data;
|
|
492
|
+
|
|
493
|
+
const attributeGroups = new Map<string, string[]>();
|
|
494
|
+
for (const item of listing.items ?? []) {
|
|
495
|
+
for (const attribute of item.attributes ?? []) {
|
|
496
|
+
const groupName = firstString(attribute.attributeTypeName) || "option";
|
|
497
|
+
const values = attributeGroups.get(groupName) ?? [];
|
|
498
|
+
const value = firstString(attribute.attributeValueName);
|
|
499
|
+
if (value && !values.includes(value)) values.push(value);
|
|
500
|
+
attributeGroups.set(groupName, values);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const images = (listing.images ?? [])
|
|
505
|
+
.slice()
|
|
506
|
+
.sort((a, b) => (a.imageOrder ?? 0) - (b.imageOrder ?? 0))
|
|
507
|
+
.map((image) => firstString(image.cdnPath, image.vendorPath))
|
|
508
|
+
.filter(Boolean);
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
source: "coupang",
|
|
512
|
+
productNo,
|
|
513
|
+
productName: firstString(listing.sellerProductName),
|
|
514
|
+
summaryDescription: "",
|
|
515
|
+
simpleDescription: "",
|
|
516
|
+
detailContent: "",
|
|
517
|
+
categories: [firstString(listing.displayCategoryCode)].filter(Boolean),
|
|
518
|
+
options: [...attributeGroups.entries()]
|
|
519
|
+
.map(([groupName, values]) => `${groupName}: ${values.join(", ")}`)
|
|
520
|
+
.filter(Boolean),
|
|
521
|
+
representativeImageUrl: images[0] ?? "",
|
|
522
|
+
specText: "",
|
|
523
|
+
shippingText: "",
|
|
524
|
+
productInfoVersion: firstString(params.knownProductInfoVersion, "unversioned"),
|
|
525
|
+
backendSourceImageUrls: images,
|
|
526
|
+
backendKeywordCandidates: [],
|
|
527
|
+
backendSpecShipping: "",
|
|
528
|
+
backendToneSources: [],
|
|
529
|
+
degraded: [],
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── 디스패치 ──────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
async function settled<T>(
|
|
536
|
+
promise: Promise<T>,
|
|
537
|
+
): Promise<{ value: T | null; error: unknown }> {
|
|
538
|
+
try {
|
|
539
|
+
return { value: await promise, error: null };
|
|
540
|
+
} catch (error) {
|
|
541
|
+
return { value: null, error };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* `host`가 첫 인자인 이유: 이 파일은 훅을 못 쓴다(컴포넌트가 아니다). 소싱 서버 호출을
|
|
547
|
+
* 모듈 수준에서 import 하면 셸이 앱에 박히므로, 데이터 접근은 호출부가 넘긴다.
|
|
548
|
+
*/
|
|
549
|
+
export function listDetailPageProducts(
|
|
550
|
+
host: DetailPageHost,
|
|
551
|
+
source: DetailPageProductSource,
|
|
552
|
+
params: DetailPageProductListParams,
|
|
553
|
+
): Promise<DetailPageProductListPage> {
|
|
554
|
+
switch (source) {
|
|
555
|
+
case "naver":
|
|
556
|
+
return listNaverProducts(host, params);
|
|
557
|
+
case "coupang":
|
|
558
|
+
return listCoupangProducts(host, params);
|
|
559
|
+
case "cafe24":
|
|
560
|
+
default:
|
|
561
|
+
return listCafe24Products(host, params);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function loadDetailPageProductSnapshot(
|
|
566
|
+
host: DetailPageHost,
|
|
567
|
+
source: DetailPageProductSource,
|
|
568
|
+
productNo: string,
|
|
569
|
+
params: DetailPageSnapshotParams = {},
|
|
570
|
+
): Promise<DetailPageProductSnapshot> {
|
|
571
|
+
switch (source) {
|
|
572
|
+
case "naver":
|
|
573
|
+
return loadNaverSnapshot(host, productNo, params);
|
|
574
|
+
case "coupang":
|
|
575
|
+
return loadCoupangSnapshot(host, productNo, params);
|
|
576
|
+
case "cafe24":
|
|
577
|
+
default:
|
|
578
|
+
return loadCafe24Snapshot(host, productNo, params);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** 카페24만 지원하는 목록 필터를 노출할지 여부. */
|
|
583
|
+
export function supportsCafe24ListFilters(
|
|
584
|
+
source: DetailPageProductSource,
|
|
585
|
+
): boolean {
|
|
586
|
+
return source === "cafe24";
|
|
587
|
+
}
|