(() => { "use strict"; let csrf = ""; let categories = []; let products = []; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; async function api(path, options = {}) { const opts = { credentials: "same-origin", headers: { "Content-Type": "application/json", ...(options.headers || {}) }, ...options, }; if (opts.body && typeof opts.body !== "string") { opts.body = JSON.stringify(opts.body); } const method = (opts.method || "GET").toUpperCase(); if (method !== "GET" && method !== "HEAD" && csrf) { opts.headers["X-CSRF-Token"] = csrf; } const res = await fetch(path, opts); let data = null; const text = await res.text(); try { data = text ? JSON.parse(text) : null; } catch { data = { error: text || "invalid response" }; } if (!res.ok) { const err = new Error((data && data.error) || res.statusText); err.status = res.status; err.data = data; throw err; } return data; } function show(el) { el.classList.remove("hidden"); } function hide(el) { el.classList.add("hidden"); } function formatPrice(n) { const v = Number(n) || 0; return new Intl.NumberFormat("fa-IR", { maximumFractionDigits: 0 }).format(v); } function setPanel(name) { $$(".panel").forEach((p) => hide(p)); show($(`#panel-${name}`)); $$(".nav-btn[data-panel]").forEach((b) => { b.classList.toggle("active", b.dataset.panel === name); }); } function closeModal() { const root = $("#modalRoot"); root.innerHTML = ""; hide(root); } function openModal(html) { const root = $("#modalRoot"); root.innerHTML = ``; show(root); root.addEventListener("click", (e) => { if (e.target === root) closeModal(); }, { once: true }); } async function refreshSession() { const s = await api("/api/session"); if (!s.authenticated) { hide($("#appView")); show($("#loginView")); return false; } csrf = s.csrf; $("#whoami").textContent = s.username; hide($("#loginView")); show($("#appView")); return true; } async function loadStats() { const s = await api("/api/stats"); $("#statProducts").textContent = formatPrice(s.products).replace(/٬/g, "٬"); $("#statCategories").textContent = s.categories; $("#statStock").textContent = s.stockUnits; } function categoryLabel(c) { if (!c) return "—"; return c.nameFa || c.name || c.slug || "—"; } function categoryFaPath(categoryPath, fallbackFa) { if (fallbackFa) return fallbackFa; const parts = String(categoryPath || "") .split("/") .map((s) => s.trim()) .filter(Boolean); if (!parts.length) return "—"; const translated = parts.map((part) => { const slug = part .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); const hit = categories.find( (c) => c.slug === slug || String(c.name || "").toUpperCase() === part.toUpperCase() ); return hit?.nameFa || part; }); return translated[translated.length - 1] || translated.join(" / "); } async function loadCategories() { const data = await api("/api/categories"); categories = data.categories || []; const tbody = $("#categoryRows"); tbody.innerHTML = categories .map((c) => { const parent = c.parentSlug ? categories.find((x) => x.slug === c.parentSlug) : null; return ` ${esc(categoryLabel(c))}${ c.nameFa && c.name ? `
${esc(c.name)}
` : "" } ${esc(c.slug)} ${esc(parent ? categoryLabel(parent) : "—")} ${c.sortOrder} ${c.active ? "فعال" : "غیرفعال"}
`; }) .join(""); } async function loadProducts(q = "") { const data = await api("/api/products" + (q ? `?q=${encodeURIComponent(q)}` : "")); products = data.products || []; const tbody = $("#productRows"); if (!products.length) { tbody.innerHTML = `محصولی نیست.`; return; } tbody.innerHTML = products .map((p) => { const variants = Array.isArray(p.colorVariants) ? p.colorVariants : []; const variantStock = variants.reduce((n, v) => n + (Number(v.stock) || 0), 0); const stock = variants.length ? variantStock : p.stock; const shades = variants .slice(0, 4) .map((v) => v.shade || v.slug) .filter(Boolean) .join("، "); return ` ${esc(p.name)}
${esc(p.slug)}
${esc(categoryFaPath(p.category, p.categoryFa))}${ p.category ? `
${esc(p.category)}
` : "" } ${ variants.length ? `${variants.length} رنگ
${esc(shades)}${ variants.length > 4 ? "…" : "" }
` : `بدون واریانت` } ${formatPrice(p.price)} ${stock} ${p.active ? "فعال" : "غیرفعال"}
`; }) .join(""); } function esc(s) { return String(s ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function resolveCategorySlug(value) { if (!value) return ""; if (categories.some((c) => c.slug === value)) return value; const norm = String(value).toUpperCase().replace(/-/g, " "); for (const c of categories) { const nameNorm = String(c.name || "").toUpperCase(); if (nameNorm && norm.includes(nameNorm)) return c.slug; } const leaf = String(value).split("/").pop()?.trim() || ""; const leafSlug = leaf .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); if (categories.some((c) => c.slug === leafSlug)) return leafSlug; return ""; } function categoryPathForSlug(slug) { if (!slug) return ""; const cat = categories.find((c) => c.slug === slug); if (!cat) return slug; const chain = []; let cur = cat; const seen = new Set(); while (cur && !seen.has(cur.slug)) { seen.add(cur.slug); chain.unshift(String(cur.name || cur.slug).toUpperCase()); cur = cur.parentSlug ? categories.find((c) => c.slug === cur.parentSlug) : null; } return chain.join(" / "); } function categoryOptions(selected) { const resolved = resolveCategorySlug(selected); let extra = ""; if (selected && !resolved && !categories.some((c) => c.slug === selected)) { extra = ``; } const roots = categories.filter((c) => !c.parentSlug); const childrenOf = (slug) => categories.filter((c) => c.parentSlug === slug); const lines = []; const walk = (list, depth) => { for (const c of list) { const pad = depth ? `${"— ".repeat(depth)}` : ""; const label = categoryLabel(c); lines.push( `` ); walk(childrenOf(c.slug), depth + 1); } }; walk(roots.length ? roots : categories, 0); // orphans (parent missing) for (const c of categories) { if ( c.parentSlug && !categories.some((x) => x.slug === c.parentSlug) && !lines.some((html) => html.includes(`value="${esc(c.slug)}"`)) ) { lines.push( `` ); } } return extra + lines.join(""); } function variantRowHtml(v = {}, index = 0) { const hex = v.colorHex || "#cccccc"; return `
واریانت ${index + 1}
`; } function productForm(p = {}) { const variants = Array.isArray(p.colorVariants) ? p.colorVariants : []; return `

${p.id ? "ویرایش محصول" : "محصول جدید"}

دریافت از KIKO / اینترنت

توضیح همین محصول را از صفحه رسمی kikocosmetics.com می‌گیرد. روی همین محصول اعمال می‌شود و خانواده تکراری نمی‌سازد.

${p.id ? `` : ""}

واریانت‌ها (رنگ / شید / …)

هر رنگ موجودی، قیمت و تصویر خودش را دارد — مثل فروشگاه کیکو.

${ variants.length ? variants.map((v, i) => variantRowHtml(v, i)).join("") : `

هنوز واریانتی نیست. برای محصولات رنگی اضافه کنید.

` }
`; } function collectVariants(form) { return $$("[data-variant-row]", form).map((row) => { const get = (key) => row.querySelector(`[data-v="${key}"]`)?.value ?? ""; const images = get("images") .split(",") .map((s) => s.trim()) .filter(Boolean); const compare = get("compareAtPrice"); return { ...JSON.parse(row.dataset.original || "{}"), name: get("name"), description: get("description"), seoTitle: get("seoTitle"), seoDescription: get("seoDescription"), id: get("id") || undefined, shade: get("shade").trim(), colorHex: get("colorHex") || null, slug: get("slug").trim(), price: get("price") === "" ? 0 : Number(get("price")), compareAtPrice: compare === "" ? null : Number(compare), stock: Number(get("stock") || 0), sku: get("sku").trim() || null, externalId: get("externalId").trim() || null, heroImage: get("heroImage").trim() || null, images, }; }).filter((v) => v.shade); } function bindVariantEditor(form) { const list = $("#variantList", form); const renumber = () => { $$("[data-variant-row]", list).forEach((row, i) => { const title = row.querySelector(".variant-card-top strong"); if (!title) return; const swatch = title.querySelector(".variant-swatch"); title.innerHTML = ""; if (swatch) title.appendChild(swatch); title.append(` واریانت ${i + 1}`); }); const empty = $("#variantEmpty", list); if (empty) empty.remove(); if (!$$("[data-variant-row]", list).length) { list.innerHTML = `

هنوز واریانتی نیست. برای محصولات رنگی اضافه کنید.

`; } }; $("#addVariantBtn", form).onclick = () => { const empty = $("#variantEmpty", list); if (empty) empty.remove(); list.insertAdjacentHTML("beforeend", variantRowHtml({}, $$("[data-variant-row]", list).length)); renumber(); }; list.onclick = (e) => { const btn = e.target.closest("[data-remove-variant]"); if (!btn) return; btn.closest("[data-variant-row]")?.remove(); renumber(); }; list.oninput = (e) => { const input = e.target.closest('[data-v="colorHex"]'); if (!input) return; const row = input.closest("[data-variant-row]"); const swatch = row?.querySelector(".variant-swatch"); if (swatch) swatch.style.background = input.value; }; } function categoryForm(c = {}) { return `

${c.slug ? "ویرایش دسته" : "دسته جدید"}

`; } function formToObject(form) { const fd = new FormData(form); const obj = {}; for (const [k, v] of fd.entries()) obj[k] = v; obj.active = form.querySelector('[name="active"]')?.checked ?? true; return obj; } function applyKikoToForm(form, fetched, { overwriteDescription = false } = {}) { if (!fetched) return; const desc = form.querySelector('[name="description"]'); const sku = form.querySelector('[name="sku"]'); const externalId = form.querySelector('[name="externalId"]'); const kikoUrl = form.querySelector('[name="kikoUrl"]'); const currentDesc = (desc?.value || "").trim(); if (desc) { if (overwriteDescription || !currentDesc) { desc.value = fetched.description || fetched.sourceDescription || desc.value; } } if (sku && !sku.value && fetched.sku) sku.value = fetched.sku; if (externalId && !externalId.value && fetched.externalId) { externalId.value = fetched.externalId; } if (kikoUrl && fetched.sourceUrl) kikoUrl.value = fetched.sourceUrl; // Merge shade media/text onto matching variant rows (no detail tables). const incoming = fetched.colorVariants || []; $$("[data-variant-row]", form).forEach((row) => { const shade = row.querySelector('[data-v="shade"]')?.value?.trim() || ""; const vsku = row.querySelector('[data-v="sku"]')?.value?.trim() || ""; const vslug = row.querySelector('[data-v="slug"]')?.value?.trim() || ""; const match = incoming.find((v) => v.slug && v.slug === vslug) || incoming.find((v) => v.sku && v.sku === vsku) || incoming.find((v) => v.shade && shade && String(v.shade).includes(shade.split(" ")[0])); if (!match) return; const descEl = row.querySelector('[data-v="description"]'); const hexEl = row.querySelector('[data-v="colorHex"]'); const heroEl = row.querySelector('[data-v="heroImage"]'); if (descEl && !(descEl.value || "").trim() && match.description) { descEl.value = match.description; } if (hexEl && !hexEl.value && match.colorHex) hexEl.value = match.colorHex; if (heroEl && !heroEl.value && match.heroImage) heroEl.value = match.heroImage; if (row.querySelector('[data-v="sku"]') && !vsku && match.sku) { row.querySelector('[data-v="sku"]').value = match.sku; } if (row.querySelector('[data-v="externalId"]') && match.externalId) { const el = row.querySelector('[data-v="externalId"]'); if (el && !el.value) el.value = match.externalId; } }); } async function openProductEditor(product) { if (!categories.length) await loadCategories(); openModal(productForm(product || { active: true })); const form = $("#productForm"); $("#cancelModal").onclick = closeModal; bindVariantEditor(form); const catSelect = form.querySelector("[data-category-select]"); const catFaInput = form.querySelector("[data-category-fa]"); if (catSelect && catFaInput) { catSelect.addEventListener("change", () => { const cat = categories.find((c) => c.slug === catSelect.value); if (cat) { catFaInput.value = cat.nameFa || categoryLabel(cat); } else if (!catSelect.value) { catFaInput.value = ""; } }); } const kikoMsg = $("#kikoEnrichMsg", form); const fetchBody = () => ({ url: form.querySelector('[name="kikoUrl"]')?.value?.trim() || "", query: form.querySelector('[name="name"]')?.value?.trim() || product?.name || "", overwriteDescription: !!form.querySelector('[name="overwriteDescription"]')?.checked, market: "en-gb", }); const fetchKikoBtn = $("#fetchKikoBtn", form); if (fetchKikoBtn) { fetchKikoBtn.onclick = async () => { kikoMsg.className = "msg info"; kikoMsg.textContent = "در حال دریافت از KIKO…"; try { const body = fetchBody(); if (!body.url && !body.query) { throw new Error("نام محصول یا آدرس صفحه KIKO را وارد کنید."); } const data = await api("/api/enrich/kiko", { method: "POST", body }); applyKikoToForm(form, data.product, { overwriteDescription: body.overwriteDescription, }); kikoMsg.className = "msg ok"; kikoMsg.textContent = `دریافت شد: ${data.product?.details?.length || 0} ردیف مشخصات، ` + `${data.product?.colorVariants?.length || 0} رنگ. ` + `توضیحات ${body.overwriteDescription ? "جایگزین شد" : "فقط در صورت خالی بودن پر شد"}. ذخیره را بزنید.`; } catch (err) { kikoMsg.className = "msg error"; if (err.status === 401 || /unauthorized/i.test(err.message || "")) { kikoMsg.textContent = "نشست ادمین منقضی شده. دوباره وارد شوید، صفحه را رفرش کنید، بعد دوباره تلاش کنید."; show($("#loginView")); } else { kikoMsg.textContent = err.message; } } }; } const fetchKikoSaveBtn = $("#fetchKikoSaveBtn", form); if (fetchKikoSaveBtn && product?.id) { fetchKikoSaveBtn.onclick = async () => { kikoMsg.className = "msg info"; kikoMsg.textContent = "دریافت و ذخیره روی همین محصول…"; try { const body = fetchBody(); const data = await api( `/api/products/${encodeURIComponent(product.id)}/enrich-kiko`, { method: "POST", body } ); closeModal(); await openProductEditor(data.product); const msg = $("#kikoEnrichMsg"); if (msg) { msg.className = "msg ok"; msg.textContent = `ذخیره شد روی همین محصول · variants=${data.variantCount}`; } await loadProducts($("#productSearch").value.trim()); await loadStats(); } catch (err) { kikoMsg.className = "msg error"; if (err.status === 401 || /unauthorized/i.test(err.message || "")) { kikoMsg.textContent = "نشست ادمین منقضی شده. دوباره وارد شوید، صفحه را رفرش کنید، بعد دوباره تلاش کنید."; show($("#loginView")); } else { kikoMsg.textContent = err.message; } } }; } form.onsubmit = async (e) => { e.preventDefault(); const msg = $("#formMsg"); hide(msg); const obj = {...product, ...formToObject(form)}; delete obj.kikoUrl; delete obj.overwriteDescription; if (form.querySelector('[name="kikoUrl"]')?.value?.trim()) { obj.sourceUrl = form.querySelector('[name="kikoUrl"]').value.trim(); } try { obj.colorVariants = collectVariants(form); // Detail-table fields are hidden in the editor; keep any existing // details/detailQueries already on the product (e.g. from SerpAPI). if (!Array.isArray(obj.details)) obj.details = product?.details || []; if (!obj.detailQueries || typeof obj.detailQueries !== "object") { obj.detailQueries = product?.detailQueries || {}; } } catch (err) { show(msg); msg.textContent = err.message; return; } obj.price = Number(obj.price || 0); obj.stock = Number(obj.stock || 0); obj.compareAtPrice = obj.compareAtPrice === "" ? null : Number(obj.compareAtPrice); if (obj.category && categories.some((c) => c.slug === obj.category)) { const built = categoryPathForSlug(obj.category); if (built) obj.category = built; } try { if (product?.id) { await api(`/api/products/${encodeURIComponent(product.id)}`, { method: "PUT", body: obj, }); } else { await api("/api/products", { method: "POST", body: obj }); } closeModal(); await loadProducts($("#productSearch").value.trim()); await loadStats(); } catch (err) { show(msg); msg.textContent = err.message; } }; } document.getElementById("downloadCatalogBtn").onclick = async () => { try { const data = await api("/api/export/json"); const url = URL.createObjectURL(new Blob([JSON.stringify(data)], {type:"application/json"})); const link = document.createElement("a"); link.href=url; link.download="kikomiko-catalog-v2.json"; link.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); } catch (error) { alert(error.message); } }; async function openCategoryEditor(cat) { if (!categories.length) await loadCategories(); openModal(categoryForm(cat || { active: true })); $("#cancelModal").onclick = closeModal; $("#categoryForm").onsubmit = async (e) => { e.preventDefault(); const msg = $("#formMsg"); hide(msg); const obj = formToObject(e.target); obj.sortOrder = Number(obj.sortOrder || 0); obj.parentSlug = obj.parentSlug || null; try { if (cat?.slug) { await api(`/api/categories/${encodeURIComponent(cat.slug)}`, { method: "PUT", body: obj, }); } else { await api("/api/categories", { method: "POST", body: obj }); } closeModal(); await loadCategories(); await loadStats(); } catch (err) { show(msg); msg.textContent = err.message; } }; } async function loadFetchers() { const data = await api("/api/fetchers"); const sel = $("#fetcherSelect"); sel.innerHTML = (data.fetchers || []) .map((f) => ``) .join(""); const kikoDefault = `{ "market": "en-gb", "urls": [ "https://www.kikocosmetics.com/en-gb/p/love-fusion-24h-moisture-radiant-foundation-1-52012/" ] }`; const updateDesc = () => { const f = (data.fetchers || []).find((x) => x.id === sel.value); $("#fetcherDesc").textContent = f ? `${f.description} · fetch:${f.supports_fetch ? "yes" : "no"} · sync:${f.supports_sync ? "yes" : "no"}` : ""; if (sel.value === "kikocosmetics") { const box = $("#fetcherConfig"); if (box && (!box.value.trim() || box.value.includes("sample-remote-products"))) { box.value = kikoDefault; } } }; // Prefer KIKO fetcher when available if ((data.fetchers || []).some((f) => f.id === "kikocosmetics")) { sel.value = "kikocosmetics"; } sel.onchange = updateDesc; updateDesc(); } function bindEvents() { $("#loginForm").onsubmit = async (e) => { e.preventDefault(); const msg = $("#loginMsg"); const submitBtn = e.target.querySelector('button[type="submit"]'); hide(msg); if (submitBtn) submitBtn.disabled = true; try { const data = await api("/api/login", { method: "POST", body: { username: $("#username").value.trim(), password: $("#password").value, }, }); csrf = data.csrf; $("#whoami").textContent = data.username || "admin"; $("#password").value = ""; hide($("#loginView")); show($("#appView")); await bootApp(); } catch (err) { show(msg); msg.textContent = err.message || "ورود ناموفق"; } finally { if (submitBtn) submitBtn.disabled = false; } }; $("#logoutBtn").onclick = async () => { try { await api("/api/logout", { method: "POST", body: {} }); } catch { /* ignore */ } csrf = ""; hide($("#appView")); show($("#loginView")); }; $$(".nav-btn[data-panel]").forEach((btn) => { btn.onclick = () => { setPanel(btn.dataset.panel); if (btn.dataset.panel === "settings") loadAiSettings(); }; }); $("#newProductBtn").onclick = () => openProductEditor(null); $("#newCategoryBtn").onclick = () => openCategoryEditor(null); let searchTimer; $("#productSearch").oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => loadProducts($("#productSearch").value.trim()), 200); }; $("#productRows").onclick = async (e) => { const edit = e.target.closest("[data-edit-product]"); const del = e.target.closest("[data-del-product]"); if (edit) { const p = products.find((x) => x.id === edit.dataset.editProduct); if (p) openProductEditor(p); } if (del) { if (!confirm("حذف این محصول؟")) return; await api(`/api/products/${encodeURIComponent(del.dataset.delProduct)}`, { method: "DELETE", }); await loadProducts($("#productSearch").value.trim()); await loadStats(); } }; $("#categoryRows").onclick = async (e) => { const edit = e.target.closest("[data-edit-cat]"); const del = e.target.closest("[data-del-cat]"); if (edit) { const c = categories.find((x) => x.slug === edit.dataset.editCat); if (c) openCategoryEditor(c); } if (del) { if (!confirm("حذف این دسته؟")) return; await api(`/api/categories/${encodeURIComponent(del.dataset.delCat)}`, { method: "DELETE", }); await loadCategories(); await loadStats(); } }; $("#seedCatsBtn").onclick = async () => { const out = $("#importOut"); try { const r = await api("/api/import/categories", { method: "POST", body: {} }); out.textContent = JSON.stringify(r, null, 2); await loadCategories(); await loadStats(); setPanel("import"); } catch (err) { out.textContent = err.message; } }; $("#importDistBtn").onclick = async () => { const out = $("#importOut"); try { const r = await api("/api/import/catalog", { method: "POST", body: { source: "dist" }, }); out.textContent = JSON.stringify(r, null, 2); await loadProducts(); await loadStats(); } catch (err) { out.textContent = err.message; } }; $("#importFile").onchange = async (e) => { const file = e.target.files?.[0]; if (!file) return; const out = $("#importOut"); try { const text = await file.text(); const data = JSON.parse(text); const r = await api("/api/import/catalog", { method: "POST", body: { source: "inline", data }, }); out.textContent = JSON.stringify(r, null, 2); await loadProducts(); await loadStats(); } catch (err) { out.textContent = err.message; } finally { e.target.value = ""; } }; $("#downloadDbBtn").onclick = async () => { const out = $("#importOut"); try { const res = await fetch("/api/export/database", { credentials: "same-origin" }); if (!res.ok) { let msg = res.statusText; try { const data = await res.json(); msg = data.error || msg; } catch { /* ignore */ } throw new Error(msg); } const blob = await res.blob(); const cd = res.headers.get("Content-Disposition") || ""; const match = cd.match(/filename="?([^"]+)"?/); const name = match?.[1] || `kikomiko-${Date.now()}.db`; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); out.textContent = JSON.stringify( { ok: true, downloaded: name, bytes: blob.size }, null, 2 ); } catch (err) { out.textContent = err.message; } }; $("#importDbFile").onchange = async (e) => { const file = e.target.files?.[0]; if (!file) return; const out = $("#importOut"); if ( !confirm( "بازیابی دیتابیس فعلی را جایگزین می‌کند. یک نسخه پشتیبان خودکار ساخته می‌شود. ادامه؟" ) ) { e.target.value = ""; return; } try { const res = await fetch("/api/import/database", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/octet-stream", "X-CSRF-Token": csrf, }, body: file, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = { error: text || "invalid response" }; } if (!res.ok) { throw new Error((data && data.error) || res.statusText); } out.textContent = JSON.stringify(data, null, 2) + "\n\nاگر نشست منقضی شد، دوباره وارد شوید. بعد از بازیابی، «خروجی به catalog.json» را از داشبورد بزنید."; try { await loadCategories(); await loadProducts(); await loadStats(); } catch { /* session may belong to previous DB — user re-logins */ } } catch (err) { out.textContent = err.message; } finally { e.target.value = ""; } }; $("#runFetcherBtn").onclick = async () => { const out = $("#importOut"); let config; try { config = JSON.parse($("#fetcherConfig").value || "{}"); } catch { out.textContent = "پیکربندی JSON نامعتبر است"; return; } try { const r = await api("/api/fetch/run", { method: "POST", body: { fetcher: $("#fetcherSelect").value, action: $("#fetcherAction").value, config, }, }); out.textContent = JSON.stringify(r, null, 2); await loadProducts(); await loadStats(); } catch (err) { out.textContent = err.message; } }; const kikoBulkControls = () => [ $("#kikoBulkUrlsBtn"), $("#kikoBulkExistingBtn"), $("#kikoDiscoverBtn"), $("#kikoCatalogAllBtn"), ].filter(Boolean); const setKikoBusy = (busy) => { for (const b of kikoBulkControls()) b.disabled = busy; }; const runKikoBulk = async (mode) => { const out = $("#importOut"); const msg = $("#kikoBulkMsg"); msg.className = "msg"; setKikoBusy(true); try { const body = { mode, market: $("#kikoBulkMarket").value.trim() || "en-gb", limit: Number($("#kikoBulkLimit").value || 15), overwriteDescription: $("#kikoBulkOverwrite").checked, onlyMissing: true, skipExisting: $("#kikoBulkSkipExisting")?.checked !== false, }; if (mode === "urls") { body.urls = $("#kikoBulkUrls").value || ""; if (!String(body.urls).trim()) { throw new Error("حداقل یک لینک محصول KIKO وارد کنید."); } } const r = await api("/api/enrich/kiko-bulk", { method: "POST", body }); msg.classList.add("ok"); msg.textContent = `گرفته شد: ${r.fetched || 0} · جدید: ${r.created || 0} · به‌روز: ${r.updated || 0} · خطا: ${(r.fetchErrors || r.errors || []).length}. حالا از داشبورد خروجی catalog را بزن.`; show(msg); out.textContent = JSON.stringify(r, null, 2); await loadProducts(); await loadStats(); return r; } catch (err) { msg.classList.add("error"); msg.textContent = err.message; show(msg); out.textContent = err.message; throw err; } finally { setKikoBusy(false); } }; const runKikoDiscover = async () => { const out = $("#importOut"); const msg = $("#kikoBulkMsg"); msg.className = "msg"; setKikoBusy(true); try { const r = await api("/api/enrich/kiko-discover", { method: "POST", body: { market: $("#kikoBulkMarket").value.trim() || "en-gb", onePerFamily: true, skipExisting: $("#kikoBulkSkipExisting")?.checked !== false, }, }); const urls = r.urls || []; $("#kikoBulkUrls").value = urls.join("\n"); msg.classList.add("ok"); msg.textContent = `کشف شد: ${r.familyUrls || 0} خانواده · شیدها: ${r.shadeUrls || 0} · از قبل بود: ${r.alreadyPresent || 0} · آماده ورود: ${r.pending || urls.length}`; show(msg); out.textContent = JSON.stringify( { market: r.market, shadeUrls: r.shadeUrls, familyUrls: r.familyUrls, alreadyPresent: r.alreadyPresent, pending: r.pending, sample: urls.slice(0, 8), }, null, 2 ); return r; } catch (err) { msg.classList.add("error"); msg.textContent = err.message; show(msg); out.textContent = err.message; throw err; } finally { setKikoBusy(false); } }; const runKikoCatalogAll = async () => { const out = $("#importOut"); const msg = $("#kikoBulkMsg"); const limit = Number($("#kikoBulkLimit").value || 15); const market = $("#kikoBulkMarket").value.trim() || "en-gb"; const overwriteDescription = $("#kikoBulkOverwrite").checked; const skipExisting = $("#kikoBulkSkipExisting")?.checked !== false; msg.className = "msg"; setKikoBusy(true); let totalFetched = 0; let totalCreated = 0; let totalUpdated = 0; let totalErrors = 0; let round = 0; const log = []; try { // Keep calling catalog mode until pendingAfter is 0 (always offset 0 + skipExisting). while (true) { round += 1; msg.classList.add("info"); msg.textContent = `در حال ورود خودکار… دور ${round} · تا الان گرفته‌شده: ${totalFetched}`; show(msg); const r = await api("/api/enrich/kiko-bulk", { method: "POST", body: { mode: "catalog", market, limit, offset: 0, overwriteDescription, skipExisting, }, }); totalFetched += r.fetched || 0; totalCreated += r.created || 0; totalUpdated += r.updated || 0; totalErrors += (r.fetchErrors || r.errors || []).length; log.push({ round, fetched: r.fetched, created: r.created, updated: r.updated, pendingAfter: r.pendingAfter, alreadyPresent: r.alreadyPresent, errors: (r.fetchErrors || []).slice(0, 5), }); out.textContent = JSON.stringify( { totalFetched, totalCreated, totalUpdated, totalErrors, pendingAfter: r.pendingAfter, familyUrls: r.familyUrls, rounds: log, }, null, 2 ); await loadProducts(); await loadStats(); if (r.done || !(r.pendingAfter > 0) || !(r.fetched > 0 || (r.fetchErrors || []).length)) { break; } // If a full batch failed, stop to avoid infinite loop. if (!(r.fetched > 0) && (r.fetchErrors || []).length >= limit) { throw new Error("چندین خطای پشت‌سرهم از سایت اصلی؛ بعداً دوباره تلاش کنید."); } } msg.className = "msg ok"; msg.textContent = `تمام شد. گرفته شد: ${totalFetched} · جدید: ${totalCreated} · به‌روز: ${totalUpdated} · خطا: ${totalErrors}. حالا از داشبورد خروجی catalog را بزنید.`; show(msg); } catch (err) { msg.className = "msg error"; msg.textContent = err.message; show(msg); out.textContent = err.message; } finally { setKikoBusy(false); } }; $("#kikoBulkUrlsBtn").onclick = () => { if (!confirm("همهٔ لینک‌ها از KIKO گرفته و وارد ادمین شوند؟")) return; runKikoBulk("urls"); }; $("#kikoBulkExistingBtn").onclick = () => { if (!confirm("محصولات ناقص موجود از KIKO تکمیل شوند؟ (ممکن است چند دقیقه طول بکشد)")) return; runKikoBulk("existing"); }; $("#kikoDiscoverBtn").onclick = () => { runKikoDiscover(); }; $("#kikoCatalogAllBtn").onclick = () => { if ( !confirm( "همهٔ محصولات جدید از نقشهٔ سایت اصلی کشف و دسته‌دسته وارد شوند؟ ممکن است ۳۰ تا ۹۰ دقیقه طول بکشد. صفحه را باز نگه دارید." ) ) { return; } runKikoCatalogAll(); }; $("#exportBtn").onclick = async () => { const out = $("#dashOut"); show(out); try { const r = await api("/api/export/catalog", { method: "POST", body: {} }); out.textContent = JSON.stringify(r, null, 2); } catch (err) { out.textContent = err.message; } }; $("#passwordForm").onsubmit = async (e) => { e.preventDefault(); const msg = $("#passMsg"); msg.className = "msg"; try { await api("/api/password", { method: "POST", body: { current: $("#currentPass").value, newPassword: $("#newPass").value, }, }); msg.classList.add("ok"); msg.textContent = "رمز تغییر کرد. دوباره وارد شوید."; show(msg); setTimeout(() => { hide($("#appView")); show($("#loginView")); }, 800); } catch (err) { msg.classList.add("error"); msg.textContent = err.message; show(msg); } }; $("#aiSettingsForm").onsubmit = async (e) => { e.preventDefault(); const msg = $("#aiSettingsMsg"); msg.className = "msg"; try { const body = { baseUrl: $("#aiBaseUrl").value.trim(), model: $("#aiModel").value.trim(), }; const key = $("#aiApiKey").value.trim(); if (key) body.apiKey = key; const serpKey = $("#serpApiKey").value.trim(); if (serpKey) body.serpApiKey = serpKey; const catKey = $("#categoryApiKey").value.trim(); if (catKey) body.categoryApiKey = catKey; const data = await api("/api/settings/ai", { method: "POST", body }); $("#aiApiKey").value = ""; $("#serpApiKey").value = ""; $("#categoryApiKey").value = ""; applyAiSettings(data); msg.classList.add("ok"); const parts = []; if (data.hasKey) parts.push("OpenAI"); if (data.hasSerpKey) parts.push("SerpAPI"); if (data.hasCategoryKey) parts.push("دسته‌ها"); msg.textContent = parts.length ? `تنظیمات AI ذخیره شد (${parts.join(" + ")}).` : "تنظیمات ذخیره شد (هنوز کلیدی ثبت نشده)."; show(msg); } catch (err) { msg.classList.add("error"); msg.textContent = err.message; show(msg); } }; const runTranslate = async (dryRun) => { const msg = $("#aiTranslateMsg"); const out = $("#aiTranslateOut"); const btn = dryRun ? $("#aiTranslateDryRunBtn") : $("#aiTranslateBtn"); msg.className = "msg"; out.textContent = ""; btn.disabled = true; try { const data = await api("/api/translate/descriptions", { method: "POST", body: { dryRun: !!dryRun, overwrite: $("#aiOverwrite").checked, limit: Number($("#aiTranslateLimit").value || 25), onlyActive: true, }, }); msg.classList.add("ok"); if (dryRun) { msg.textContent = `${data.processed || 0} محصول نیازمند ترجمه (نمایش حداکثر ${data.results?.length || 0}). باقی‌مانده بعد از این دسته: ${data.remaining || 0}`; } else { msg.textContent = `ترجمه شد: ${data.translatedProducts || 0} محصول / ${data.translatedFields || 0} فیلد. باقی‌مانده: ${data.remaining || 0}. بعد از اتمام، کاتالوگ را Export کنید.`; } show(msg); out.textContent = JSON.stringify(data, null, 2); if (!dryRun && (data.translatedProducts || 0) > 0) { await loadProducts($("#productSearch").value.trim()); } } catch (err) { msg.classList.add("error"); msg.textContent = err.message; show(msg); } finally { btn.disabled = false; } }; $("#aiTranslateDryRunBtn").onclick = () => runTranslate(true); $("#aiTranslateBtn").onclick = () => { if (!confirm("توضیحات انگلیسی محصولات به فارسی ترجمه شود؟ (هزینه API بر اساس کلید شما محاسبه می‌شود)")) { return; } runTranslate(false); }; const runSerpFill = async (dryRun) => { const msg = $("#serpFillMsg"); const out = $("#aiTranslateOut"); const btn = dryRun ? $("#serpDryRunBtn") : $("#serpFillBtn"); if (!msg || !btn) return; msg.className = "msg"; if (out) out.textContent = ""; btn.disabled = true; try { const data = await api("/api/enrich/details-serp", { method: "POST", body: { dryRun: !!dryRun, overwrite: $("#serpOverwrite")?.checked || false, limit: Number($("#serpFillLimit")?.value || 10), onlyActive: true, }, }); msg.classList.add("ok"); if (dryRun) { msg.textContent = `${data.processed || 0} محصول با مشخصات ناقص. باقی‌مانده: ${data.remaining || 0}`; } else { msg.textContent = `پر شد: ${data.filledProducts || 0} محصول / ${data.filledFields || 0} فیلد. باقی‌مانده: ${data.remaining || 0}. سپس Export کاتالوگ را بزنید.`; } show(msg); if (out) out.textContent = JSON.stringify(data, null, 2); if (!dryRun && (data.filledProducts || 0) > 0) { await loadProducts($("#productSearch").value.trim()); } } catch (err) { msg.classList.add("error"); msg.textContent = err.message; show(msg); } finally { btn.disabled = false; } }; if ($("#serpDryRunBtn")) { $("#serpDryRunBtn").onclick = () => runSerpFill(true); } if ($("#serpFillBtn")) { $("#serpFillBtn").onclick = () => { if (!confirm("مشخصات خالی با SerpAPI و کوئری‌های detailQueries پر شود؟ (مصرف سهمیه SerpAPI)")) { return; } runSerpFill(false); }; } const runCatTranslate = async (dryRun) => { const msg = $("#catTranslateMsg"); const out = $("#aiTranslateOut"); const btn = dryRun ? $("#catTranslateDryRunBtn") : $("#catTranslateBtn"); msg.className = "msg"; out.textContent = ""; btn.disabled = true; try { const data = await api("/api/translate/categories", { method: "POST", body: { dryRun: !!dryRun, overwrite: $("#catOverwrite").checked, limit: Number($("#catTranslateLimit").value || 50), }, }); msg.classList.add("ok"); if (dryRun) { msg.textContent = `${data.processed || 0} دسته نیازمند ترجمه. باقی‌مانده: ${data.remaining || 0}`; } else { msg.textContent = `ترجمه شد: ${data.translatedCategories || 0} دسته. باقی‌مانده: ${data.remaining || 0}. سپس Export کاتالوگ را بزنید.`; } show(msg); out.textContent = JSON.stringify(data, null, 2); if (!dryRun && (data.translatedCategories || 0) > 0) { await loadCategories(); } } catch (err) { msg.classList.add("error"); msg.textContent = err.message; show(msg); } finally { btn.disabled = false; } }; $("#catTranslateDryRunBtn").onclick = () => runCatTranslate(true); $("#catTranslateBtn").onclick = () => { if (!confirm("نام دسته‌ها به فارسی ترجمه شود؟ (مصرف کلید ترجمه دسته‌ها / OpenAI)")) { return; } runCatTranslate(false); }; } function applyAiSettings(data) { if (!data) return; $("#aiBaseUrl").value = data.baseUrl || "https://api.openai.com/v1"; $("#aiModel").value = data.model || "gpt-4o-mini"; $("#aiKeyHint").textContent = data.hasKey ? `کلید OpenAI ذخیره‌شده: ${data.keyHint || "••••"}` : "هنوز کلید OpenAI ذخیره نشده است."; const serpHint = $("#serpKeyHint"); if (serpHint) { serpHint.textContent = data.hasSerpKey ? `کلید SerpAPI ذخیره‌شده: ${data.serpKeyHint || "••••"}` : "هنوز کلید SerpAPI ذخیره نشده است (اختیاری)."; } const catHint = $("#categoryKeyHint"); if (catHint) { catHint.textContent = data.hasCategoryKey ? `کلید ترجمه دسته‌ها: ${data.categoryKeyHint || "••••"}` : "کلید جداگانه ندارید — از کلید OpenAI اصلی استفاده می‌شود."; } } async function loadAiSettings() { try { const data = await api("/api/settings/ai"); applyAiSettings(data); } catch { /* ignore until logged in */ } } async function bootApp() { await loadStats(); await loadCategories(); await loadProducts(); await loadFetchers(); await loadAiSettings(); setPanel("dashboard"); } async function main() { bindEvents(); const ok = await refreshSession(); if (ok) await bootApp(); } main().catch((err) => { console.error(err); show($("#loginView")); const msg = $("#loginMsg"); show(msg); msg.textContent = "خطا در بارگذاری پنل"; }); })();