// === Product detail viewer === function ProductDetail({ productId, products, setProducts, setAllProducts, onClose, productsSource }) { const product = products.find(p => p.id === productId); if (!product) { console.warn("[ProductDetail] 상품 못 찾음. productId=", productId, "products.length=", products?.length); return ( 닫기}>
ID {productId} 상품을 찾을 수 없습니다.
전체 상품 로드가 끝났는지 확인하거나, 새로고침 후 다시 시도해주세요.
); } const isLive = productsSource === "live"; const cat = window.MOCK.CATEGORIES.find(c => c.id === product.category); const [tab, setTab] = useState("overview"); const margin = product.costPrice > 0 ? ((product.price - product.costPrice) / product.price * 100).toFixed(1) : null; // 라이브 모드: GET origin-product로 전체 상세 데이터 가져옴 const [liveData, setLiveData] = useState(null); const [loadingLive, setLoadingLive] = useState(false); const [liveError, setLiveError] = useState(null); const [edits, setEdits] = useState({}); // { salePrice?, stockQuantity?, immediateDiscount?, name? } const [saving, setSaving] = useState(false); useEffect(() => { if (!isLive || !product.originProductNo) return; setLoadingLive(true); setLiveError(null); window.API.naverFetchOriginProduct(product.originProductNo) .then(data => setLiveData(data)) .catch(err => { console.error("[detail-fetch]", err); setLiveError(err.message); }) .finally(() => setLoadingLive(false)); }, [isLive, product.originProductNo]); // 표시할 값 — 편집 중이면 edits, 아니면 liveData(라이브) 또는 product(mock) const liveOP = liveData?.originProduct; const cur = isLive && liveOP ? { name: liveOP.name, salePrice: liveOP.salePrice, stockQuantity: liveOP.stockQuantity, immediateDiscount: liveOP.customerBenefit?.immediateDiscountPolicy?.discountMethod?.value || 0, statusType: liveOP.statusType, detailContent: liveOP.detailContent || "", representativeImageUrl: liveOP.images?.representativeImage?.url || product.imageUrl, optionalImages: liveOP.images?.optionalImages || [], wholeCategoryName: product.categoryFull, leafCategoryId: liveOP.leafCategoryId, } : { name: product.name, salePrice: product.price, stockQuantity: product.stock, immediateDiscount: product.price - (product.discountedPrice || product.price), statusType: product.status === "active" ? "SALE" : "OUTOFSTOCK", detailContent: product.desc || "", representativeImageUrl: product.imageUrl, optionalImages: [], wholeCategoryName: cat?.name || "", leafCategoryId: product.category, }; const get = (key) => edits[key] !== undefined ? edits[key] : cur[key]; const isDirty = (key) => edits[key] !== undefined && edits[key] !== cur[key]; const anyDirty = Object.keys(edits).some(k => edits[k] !== cur[k]); const currentDiscountedPrice = Math.max(0, (get("salePrice") || 0) - (get("immediateDiscount") || 0)); const setEdit = (key, value) => setEdits(prev => ({ ...prev, [key]: value })); const clearEdits = () => setEdits({}); // mock 저장 (기존 동작) const saveMock = () => { setProducts(prev => prev.map(p => { if (p.id !== product.id) return p; return { ...p, name: get("name"), price: get("salePrice"), stock: get("stockQuantity"), desc: get("detailContent"), }; })); toast("저장되었습니다"); onClose(); }; // 라이브 저장 — 변경된 필드만 PUT const saveLive = async () => { const changes = {}; if (isDirty("name")) changes.name = get("name"); if (isDirty("salePrice")) { const v = parseInt(get("salePrice"), 10); if (isNaN(v) || v < 0 || v % 10 !== 0) { toast("판매가는 10원 단위 정수여야 합니다"); return; } changes.salePrice = v; } if (isDirty("immediateDiscount")) { const v = parseInt(get("immediateDiscount"), 10); if (isNaN(v) || v < 0 || v % 10 !== 0) { toast("즉시할인은 10원 단위 정수여야 합니다"); return; } if (v > get("salePrice")) { toast("즉시할인이 판매가를 넘을 수 없습니다"); return; } changes.immediateDiscount = v; } if (isDirty("stockQuantity")) { const v = parseInt(get("stockQuantity"), 10); if (isNaN(v) || v < 0) { toast("재고는 0 이상이어야 합니다"); return; } changes.stockQuantity = v; } if (isDirty("detailContent")) { changes.detailContent = get("detailContent") || ""; } if (Object.keys(changes).length === 0) { toast("변경된 내용이 없습니다"); return; } setSaving(true); try { await window.API.naverUpdateProduct(product.originProductNo, changes); // 로컬 products state 업데이트 setProducts(prev => prev.map(p => { if (p.id !== product.id) return p; const newPrice = changes.salePrice !== undefined ? changes.salePrice : p.price; const newDiscount = changes.immediateDiscount !== undefined ? changes.immediateDiscount : (p.price - (p.discountedPrice || p.price)); return { ...p, name: changes.name !== undefined ? changes.name : p.name, price: newPrice, stock: changes.stockQuantity !== undefined ? changes.stockQuantity : p.stock, discountedPrice: Math.max(0, newPrice - newDiscount), status: (changes.stockQuantity !== undefined && changes.stockQuantity === 0) ? "outofstock" : p.status, }; })); toast("네이버에 저장되었습니다"); onClose(); } catch (err) { console.error("[detail-save]", err); toast("저장 실패: " + err.message); } finally { setSaving(false); } }; const onSave = isLive ? saveLive : saveMock; const storeUrl = isLive && product.channelProductNo ? `https://smartstore.naver.com/main/products/${product.channelProductNo}` : "https://smartstore.naver.com"; return ( {isLive ? `#${product.originProductNo}` : product.sku} {get("name")} {isLive && 라이브} } footer={ <> 스마트스토어에서 보기
}> {isLive && loadingLive && (
네이버에서 상세 데이터 불러오는 중...
)} {isLive && liveError && (
상세 조회 실패: {liveError}
)} {(!isLive || liveData) && (
{/* Left: gallery */}
{cur.representativeImageUrl ? ( {cur.name} ) : ( )}
{isLive && cur.optionalImages.length > 0 && (
{cur.optionalImages.slice(0, 4).map((img, i) => ( ))}
)}
최종가 (할인 적용)
{formatKRW(currentDiscountedPrice)}
판매가 {formatKRW(get("salePrice"))} {get("immediateDiscount") > 0 && ( <> · 즉시할인 -{formatNum(get("immediateDiscount"))}원 )}
재고
{formatNum(get("stockQuantity"))} {isDirty("stockQuantity") && (미적용)}
{!isLive && (
평점
{product.rating} ({formatNum(product.reviews)})
)}
{/* Right: details */}
{cat?.icon} {cur.wholeCategoryName || cat?.name || "(카테고리 없음)"} {isLive && cur.leafCategoryId && ( 카테고리 ID: {cur.leafCategoryId} )}
{[ ["overview", "기본정보"], ["pricing", "가격/재고"], ["desc", "상세설명"], ["sales", "판매통계"], ].map(([k, l]) => ( ))}
{tab === "overview" && (
setEdit("name", e.target.value)} />
{isLive ? ( ) : ( )}
{isLive &&
상태 변경은 향후 별도 작업으로
}
)} {tab === "pricing" && (
setEdit("salePrice", parseInt(e.target.value.replace(/[^0-9]/g, "")) || 0)} /> setEdit("immediateDiscount", parseInt(e.target.value.replace(/[^0-9]/g, "")) || 0)} />
실판매가 (고객 노출) {formatKRW(currentDiscountedPrice)}
{!isLive && margin !== null && (
개당 마진 {formatKRW(product.price - product.costPrice)} ({margin}%)
)}
setEdit("stockQuantity", parseInt(e.target.value) || 0)} />
)} {tab === "desc" && ( {isLive ? ( <>
블록 에디터 — 이미지를 추가/삭제/순서변경하고 텍스트와 섞을 수 있습니다. 저장 버튼을 누르면 네이버 SmartEditor 형식으로 변환되어 적용됩니다.
{ // 원본과 동일하면 dirty 표시 제거 (불필요한 PUT 방지) if (html === cur.detailContent) { setEdits(prev => { if (prev.detailContent === undefined) return prev; const { detailContent: _omit, ...rest } = prev; return rest; }); } else { setEdit("detailContent", html); } }} /> ) : (