// === ISVM 검색 → 네이버 등록 (real API) ===
const { useState: useStateIS, useMemo: useMemoIS, useEffect: useEffectIS } = React;
// 네이버 검색/등록에 방해되는 특수문자 정제.
// 네이버가 명시한 상품명 등록불가 문자: \ * ? " < >
// - * → x (사용자 요청 — 곱하기 기호 의도)
// - 나머지 → 제거
function cleanProductName(name) {
if (!name) return name;
return name
.replace(/\*/g, "x") // 별표 → 곱하기 x
.replace(/[\\?"<>]/g, "") // 네이버 차단 문자 제거
.replace(/[★☆♥♡◆◇▶◀▲▼●○■□◈◉◐◑▣▤▥▦▧▨▩※♠♣♣♤♧]/g, "") // 장식 기호 제거
.replace(/[【】〔〕〈〉《》「」『』]/g, "") // 장식 괄호 제거
.replace(/\s{2,}/g, " ") // 다중 공백 정리
.trim();
}
function ISVMScreen({ products, setProducts, goto }) {
const [keyword, setKeyword] = useStateIS("");
const [sort, setSort] = useStateIS("sales");
const [limit, setLimit] = useStateIS(500); // 한 번에 받을 최대 (ISVM은 limit만 받음, 페이지 미지원)
const [results, setResults] = useStateIS([]);
const [loading, setLoading] = useStateIS(false);
const [error, setError] = useStateIS("");
const [health, setHealth] = useStateIS(null);
const [selected, setSelected] = useStateIS(new Set());
const [registerForm, setRegisterForm] = useStateIS(null);
const [naverPreview, setNaverPreview] = useStateIS(null);
const [registered, setRegistered] = useStateIS(new Set());
const [creds, setCreds] = useStateIS(window.API.loadCreds());
// 클라이언트 측 페이지네이션 — 4 컬럼 × 8 행 = 32개/페이지
const PAGE_SIZE = 32;
const [page, setPage] = useStateIS(1);
const totalPages = Math.max(1, Math.ceil(results.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageStart = (currentPage - 1) * PAGE_SIZE;
const pagedResults = results.slice(pageStart, pageStart + PAGE_SIZE);
useEffectIS(() => {
window.API.isvmHealth().then(setHealth);
}, []);
const doSearch = async () => {
if (!keyword.trim()) { window.toast("검색어를 입력하세요"); return; }
setLoading(true); setError(""); setResults([]); setSelected(new Set()); setPage(1);
try {
const data = await window.API.isvmSearch({ keyword: keyword.trim(), limit: parseInt(limit) || 500, sort });
// 네이버 등록/검색 방해 특수문자 정제
const cleaned = data.map(item => ({ ...item, name: cleanProductName(item.name) }));
setResults(cleaned);
window.toast(`${cleaned.length}개 상품 검색 완료`);
} catch (e) {
const isCors = String(e.message || e).includes("Failed to fetch") || String(e.message || e).includes("NetworkError");
setError(isCors
? "ISVM 서버에 직접 연결할 수 없습니다 (CORS). 데모용 샘플 데이터를 표시합니다."
: `검색 실패: ${e.message}`);
// Fallback demo data so UI is testable from any origin
setResults(generateDemoResults(keyword.trim()));
} finally {
setLoading(false);
}
};
const toggleOne = (code) => {
const s = new Set(selected);
s.has(code) ? s.delete(code) : s.add(code);
setSelected(s);
};
const isLive = window.API.naverConfigured();
// 라이브 모드 등록 상태
const [templateProductId, setTemplateProductId] = useStateIS("");
const [registering, setRegistering] = useStateIS(false);
const [registerResult, setRegisterResult] = useStateIS(null);
// 상세설명 모드
const [detailMode, setDetailMode] = useStateIS("auto"); // "auto" | "manual"
// 수동 모드용: 이미지 목록 (ISVM + PC 업로드 합쳐서 관리)
// 각 항목: { id, source:'isvm'|'pc', url?:string, file?:File, kind?:string, selected:boolean }
const [detailImages, setDetailImages] = useStateIS([]);
const [loadingIsvmImages, setLoadingIsvmImages] = useStateIS(false);
const [uploadProgress, setUploadProgress] = useStateIS(null); // { done, total, label }
const openRegister = (item) => {
setRegisterResult(null);
setDetailMode("auto");
setDetailImages([]);
setUploadProgress(null);
// template 자동 선택 — 가장 최근 활성 상품 (있으면)
const firstLiveProduct = products.find(p => p.originProductNo);
setTemplateProductId(firstLiveProduct?.originProductNo ? String(firstLiveProduct.originProductNo) : "");
setRegisterForm({
product_code: item.product_code,
name: item.name,
brand: item.brand || "",
purchase_price: item.purchase_price,
// 권장 마진 60%
sale_price: Math.round((item.purchase_price * 1.6) / 10) * 10,
image_url: item.image_url,
stock: 50,
desc: `${item.name}\n\n공급사: ${item.brand || "-"} · ISVM 코드: ${item.product_code}`,
leafCategoryId: firstLiveProduct?.naverCategoryId || "50000000",
baseFee: 3000,
});
// 라이브 모드면 ISVM 다중 이미지 사전 로드 (수동 모드 진입 대비)
if (isLive && item.product_code) {
setLoadingIsvmImages(true);
window.API.isvmFetchProductImages(item.product_code)
.then(imgs => {
const list = imgs.map((img, i) => ({
id: 'isvm-' + i,
source: 'isvm',
url: img.url,
kind: img.kind,
selected: true,
}));
setDetailImages(list);
})
.catch(err => console.warn("[ISVM 이미지 로드 실패]", err.message))
.finally(() => setLoadingIsvmImages(false));
}
};
// 이미지 그리드 조작
const toggleDetailImage = (id) => {
setDetailImages(prev => prev.map(it => it.id === id ? { ...it, selected: !it.selected } : it));
};
const moveDetailImage = (id, direction) => {
setDetailImages(prev => {
const idx = prev.findIndex(it => it.id === id);
if (idx < 0) return prev;
const newIdx = direction === 'up' ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= prev.length) return prev;
const next = [...prev];
[next[idx], next[newIdx]] = [next[newIdx], next[idx]];
return next;
});
};
const removeDetailImage = (id) => {
setDetailImages(prev => prev.filter(it => it.id !== id));
};
const addPcFiles = (files) => {
const additions = Array.from(files).map((f, i) => ({
id: 'pc-' + Date.now() + '-' + i,
source: 'pc',
file: f,
url: URL.createObjectURL(f), // 미리보기용 blob URL
kind: 'pc',
selected: true,
}));
setDetailImages(prev => [...prev, ...additions]);
};
const previewNaverPayload = async () => {
if (!isLive) {
// mock 모드: 구버전 빌더로 추측 페이로드
const p = window.API.buildNaverProductPayload({
product_code: registerForm.product_code,
name: registerForm.name,
brand: registerForm.brand,
desc: registerForm.desc,
image_url: registerForm.image_url,
salePrice: parseInt(registerForm.sale_price) || 0,
stock: parseInt(registerForm.stock) || 0,
}, {
leafCategoryId: registerForm.leafCategoryId,
baseFee: parseInt(registerForm.baseFee) || 3000,
});
setNaverPreview(p);
return;
}
// 라이브 모드: template 기반 (자동 선택 — 매장의 첫 라이브 상품 사용)
if (!templateProductId) { window.toast("매장에 라이브 상품이 1개 이상 있어야 등록 가능"); return; }
try {
const tpl = await window.API.naverFetchOriginProduct(parseInt(templateProductId));
const payload = JSON.parse(JSON.stringify(tpl));
delete payload.originProductNo;
delete payload.smartstoreChannelProductNo;
payload.originProduct.name = registerForm.name;
payload.originProduct.salePrice = parseInt(registerForm.sale_price) || 0;
payload.originProduct.stockQuantity = parseInt(registerForm.stock) || 0;
payload.originProduct.leafCategoryId = registerForm.leafCategoryId;
if (registerForm.image_url) {
payload.originProduct.images = {
representativeImage: { url: registerForm.image_url },
optionalImages: [],
};
}
payload.originProduct.detailContent = `
${(registerForm.desc || registerForm.name).replace(/[<>]/g, "")}
`;
payload.originProduct.detailAttribute = payload.originProduct.detailAttribute || {};
payload.originProduct.detailAttribute.sellerCodeInfo = payload.originProduct.detailAttribute.sellerCodeInfo || {};
payload.originProduct.detailAttribute.sellerCodeInfo.sellerManagementCode = registerForm.product_code;
if (payload.originProduct.customerBenefit?.immediateDiscountPolicy) {
delete payload.originProduct.customerBenefit.immediateDiscountPolicy;
}
if (payload.smartstoreChannelProduct) {
payload.smartstoreChannelProduct.channelProductName = registerForm.name;
}
setNaverPreview(payload);
} catch (err) {
window.toast("미리보기 실패: " + err.message);
}
};
const submitRegister = async () => {
if (!isLive) {
// mock 모드: 시뮬레이션
const newId = "P" + (10300 + products.length);
const newProduct = {
id: newId, name: registerForm.name, category: "fashion",
price: parseInt(registerForm.sale_price) || 0,
costPrice: parseInt(registerForm.purchase_price) || 0,
stock: parseInt(registerForm.stock) || 0,
sales30d: 0, rating: 0, reviews: 0, status: "active", views30d: 0,
sku: registerForm.product_code, brand: registerForm.brand || "ISVM",
tone: parseInt(registerForm.product_code, 10) % 9 || 0,
desc: registerForm.desc, image_url: registerForm.image_url,
};
setProducts(prev => [...prev, newProduct]);
setRegistered(prev => new Set(prev).add(registerForm.product_code));
setRegisterForm(null); setNaverPreview(null);
window.toast(`'${newProduct.name.slice(0, 24)}…' 등록 (시뮬레이션)`);
return;
}
// 라이브 모드: 실제 POST
if (!templateProductId) {
window.toast("매장에 라이브 상품이 1개 이상 있어야 등록 가능 (카테고리 참조용)");
return;
}
setRegistering(true);
setRegisterResult(null);
try {
// 1) 사용할 이미지 결정 (자동: 모든 ISVM, 수동: 선택된 것만 순서대로)
let sourceImages = [];
if (detailMode === "auto") {
// 자동 — 미리 로드한 detailImages 전체 사용 (혹시 비어있으면 다시 조회)
if (detailImages.length === 0) {
try {
const imgs = await window.API.isvmFetchProductImages(registerForm.product_code);
sourceImages = imgs.map(img => ({ source: 'isvm', url: img.url }));
} catch (err) { console.warn("[ISVM 이미지 재조회 실패]", err.message); }
} else {
sourceImages = detailImages.map(it => ({ source: it.source, url: it.url, file: it.file }));
}
} else {
// 수동 — 선택된 것만, 그리드 순서대로
sourceImages = detailImages.filter(it => it.selected).map(it => ({ source: it.source, url: it.url, file: it.file }));
}
// 메인 이미지 URL을 첫 번째로 강제 (대표 이미지가 representativeImage에 들어가도록)
if (registerForm.image_url) {
const mainIdx = sourceImages.findIndex(s => s.url === registerForm.image_url);
if (mainIdx > 0) {
const [main] = sourceImages.splice(mainIdx, 1);
sourceImages.unshift(main);
} else if (mainIdx < 0) {
sourceImages.unshift({ source: 'isvm', url: registerForm.image_url });
}
}
if (sourceImages.length === 0) {
window.toast("등록할 이미지가 없습니다 (수동 모드면 1장 이상 선택 필요)");
setRegistering(false);
return;
}
// 2) 순차 업로드 — 진행 표시
const uploadedUrls = [];
setUploadProgress({ done: 0, total: sourceImages.length, label: "이미지 업로드 중" });
for (let i = 0; i < sourceImages.length; i++) {
const src = sourceImages[i];
try {
let naverUrl;
if (src.source === 'pc' && src.file) {
naverUrl = await window.API.naverUploadImageFile(src.file);
} else if (src.url) {
// 이미 네이버 URL이면 그대로 사용
if (/(shop-phinf\.pstatic\.net|phinf\.pstatic\.net|naver\.com)/.test(src.url)) {
naverUrl = src.url;
} else {
naverUrl = await window.API.naverUploadImageFromUrl(src.url);
}
}
if (naverUrl) uploadedUrls.push(naverUrl);
} catch (err) {
console.warn("[이미지 " + (i + 1) + "/" + sourceImages.length + " 업로드 실패]", err.message);
}
setUploadProgress({ done: i + 1, total: sourceImages.length, label: "이미지 업로드 중" });
}
if (uploadedUrls.length === 0) {
throw new Error("모든 이미지 업로드 실패");
}
// 3) detailContent SmartEditor HTML 생성 (모든 업로드된 이미지)
const detailHtml = window.API.buildSmartEditorImagesHtml(uploadedUrls);
// 4) 등록 호출 (representativeImage = 첫 URL, optionalImages는 자동으로 다음 9장)
setUploadProgress({ done: 0, total: 1, label: "네이버에 등록 중" });
const overrides = {
// 등록 직전 한번 더 sanitize — 사용자가 폼에서 직접 수정 시 * 들어갈 수 있음
name: cleanProductName(registerForm.name),
salePrice: parseInt(registerForm.sale_price) || 0,
stockQuantity: parseInt(registerForm.stock) || 0,
leafCategoryId: registerForm.leafCategoryId,
imageUrl: uploadedUrls[0],
optionalImageUrls: uploadedUrls.slice(1, 10), // 최대 9장
detailContent: detailHtml,
sellerCode: registerForm.product_code,
};
const result = await window.API.naverRegisterFromTemplate(parseInt(templateProductId), overrides);
setUploadProgress(null);
setRegisterResult({ ok: true, response: result });
setRegistered(prev => new Set(prev).add(registerForm.product_code));
window.toast(`'${registerForm.name.slice(0, 24)}…' 네이버에 등록되었습니다`);
} catch (err) {
console.error("[ISVM register]", err);
setRegisterResult({ ok: false, error: err.message });
window.toast("등록 실패: " + err.message);
} finally {
setRegistering(false);
}
};
const corsWarning = error && error.includes("CORS");
return (
ISVM → 네이버 스마트스토어 연동
ISVM 도매 사이트에서 상품을 검색하고 네이버 스마트스토어에 등록합니다
goto("settings")}>
API 설정
{/* Connection bar */}
IS
ISVM Crawler API
{window.API.ISVM_BASE}
{health === true ? "연결됨" : health === false ? "연결 실패" : "확인 중..."}
N
네이버 커머스 API
{creds.naver_client_id
? `client_id: ${creds.naver_client_id.slice(0, 6)}••••••`
: "자격증명 미설정"}
{creds.naver_client_id && creds.proxy_url ? "프록시 연결" : "프록시 필요"}
{/* Search bar */}
{error && (
{corsWarning ? "⚠️ CORS 차단" : "❌ 오류"}: {error}
{corsWarning && (
ISVM 서버에 이 도메인을 화이트리스트에 등록하거나, 자체 백엔드를 통해 프록시하세요.
)}
)}
{results.length === 0 && !loading && !error && (
키워드를 입력하고 검색하세요
ISVM 도매 사이트의 실시간 매입가/이미지가 조회됩니다
)}
{results.length > 0 && (
<>
검색 결과 {results.length}개
·
페이지 {currentPage}/{totalPages} · {pageStart + 1}~{Math.min(pageStart + PAGE_SIZE, results.length)} 표시
·
선택: {selected.size}
{selected.size > 0 && (
setSelected(new Set())}>선택 해제
)}
setPage(currentPage - 1)}>
이전
{currentPage} / {totalPages}
= totalPages} onClick={() => setPage(currentPage + 1)}>
다음
{pagedResults.map(item => {
const isReg = registered.has(item.product_code) || products.some(p => p.sku === item.product_code);
const margin = ((item.purchase_price * 1.6 - item.purchase_price) / (item.purchase_price * 1.6) * 100).toFixed(0);
return (
{item.image_url
?
{ e.target.style.display = "none"; }} />
:
}
{item.product_code}
{isReg && (
등록됨
)}
{item.name}
{item.brand || "-"}
매입가
{formatKRW(item.purchase_price)}
권장 판매가 (마진 {margin}%)
{formatKRW(Math.round(item.purchase_price * 1.6 / 10) * 10)}
openRegister(item)}>
{isReg ? <> 이미 등록됨> : <> 네이버 등록>}
);
})}
>
)}
{/* Register modal */}
{registerForm && (
{ if (!registering) { setRegisterForm(null); setNaverPreview(null); setRegisterResult(null); } }} size="lg" title="네이버 스마트스토어 상품 등록"
footer={
registerResult?.ok ? (
{ setRegisterForm(null); setNaverPreview(null); setRegisterResult(null); }}>닫기
) : (
<>
API 페이로드 미리보기
{ setRegisterForm(null); setNaverPreview(null); setRegisterResult(null); }} disabled={registering}>취소
{registering ? "등록 중..." : isLive ? "네이버에 등록" : "등록 (시뮬레이션)"}
>
)
}>
{registerForm.image_url
?
:
}
ISVM 출처
{registerForm.product_code}
매입가 {formatKRW(registerForm.purchase_price)}
{/* 상세설명 모드 (라이브) */}
{isLive && (
상세설명 이미지 ({detailMode === "auto" ? "자동" : "직접 선택"})
setDetailMode("auto")}>자동
setDetailMode("manual")}>직접 선택
{detailMode === "auto" ? (
ISVM에서 가져온 모든 이미지({loadingIsvmImages ? "로딩 중..." : detailImages.length + "장"})를 자동으로 SmartEditor 형식 HTML로 변환해서 상세설명에 삽입합니다. 메인은 대표 이미지로, 처음 10장은 갤러리 슬라이더, 모두 상세 페이지에 표시.
) : (
<>
체크된 이미지가 상세설명에 표시됩니다. 순서는 ↑↓ 버튼으로 조정. PC에서 이미지 추가도 가능.
PC에서 이미지 추가
{ addPcFiles(e.target.files); e.target.value = ""; }} />
선택: {detailImages.filter(i => i.selected).length} / 전체 {detailImages.length}장
{loadingIsvmImages &&
ISVM 이미지 로딩 중...
}
{detailImages.map((it, idx) => (
toggleDetailImage(it.id)}
onError={e => { e.target.style.background = '#fee'; e.target.alt = '로드 실패'; }} />
{idx + 1}
{it.source === "pc" ? "PC" : "ISVM"}
moveDetailImage(it.id, 'up')} disabled={idx === 0} title="앞으로">
moveDetailImage(it.id, 'down')} disabled={idx === detailImages.length - 1} title="뒤로">
removeDetailImage(it.id)} title="제거">
))}
{detailImages.length === 0 && !loadingIsvmImages && (
이미지가 없습니다. PC에서 추가하거나 자동 모드로 전환하세요.
)}
>
)}
)}
{/* 업로드 진행 상태 */}
{uploadProgress && (
{uploadProgress.label}
{uploadProgress.done} / {uploadProgress.total}
)}
{registerResult && (
{registerResult.ok ? (
<>
✓ 네이버 등록 완료
{JSON.stringify(registerResult.response, null, 2)}
상품 관리 페이지에서 새로고침하면 새 상품이 보입니다.
>
) : (
<>
✗ 등록 실패
{registerResult.error}
네이버가 누락된 필수 필드를 알려주면 기준 상품을 다른 걸로 변경해보거나, leafCategoryId를 조정해보세요.
>
)}
)}
{naverPreview && (
POST {naverPreview && "https://api.commerce.naver.com/external/v1/products"}
{JSON.stringify(naverPreview, null, 2)}
⚠️ 실제 등록은 서버 프록시({creds.proxy_url || "미설정"})를 통해 호출되어야 합니다.
액세스 토큰은 서버에서 bcrypt 서명으로 발급받습니다.
)}
)}
);
}
// Demo fallback (used when CORS blocks the real API in this preview)
function generateDemoResults(keyword) {
const seed = (s) => { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) % 999983; return h; };
return Array.from({ length: 8 }, (_, i) => {
const code = String(seed(keyword + i) % 900000 + 100000);
return {
product_code: code,
name: `${keyword} 관련 상품 ${i + 1} (데모)`,
brand: ["경성", "대한", "한솔", "글로벌"][i % 4],
purchase_price: Math.round((1000 + (seed(keyword + i) % 30000)) / 100) * 100,
image_url: "",
};
});
}
window.ISVMScreen = ISVMScreen;