(() => {
"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 = `
${html}
`;
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 ``;
}
function productForm(p = {}) {
const variants = Array.isArray(p.colorVariants) ? p.colorVariants : [];
return `
${p.id ? "ویرایش محصول" : "محصول جدید"}
`;
}
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 = "خطا در بارگذاری پنل";
});
})();