1482 lines
55 KiB
JavaScript
1482 lines
55 KiB
JavaScript
(() => {
|
|
"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 = `<div class="modal">${html}</div>`;
|
|
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 `<tr>
|
|
<td><strong>${esc(categoryLabel(c))}</strong>${
|
|
c.nameFa && c.name
|
|
? `<div style="color:var(--muted);font-size:0.8rem">${esc(c.name)}</div>`
|
|
: ""
|
|
}</td>
|
|
<td class="mono">${esc(c.slug)}</td>
|
|
<td>${esc(parent ? categoryLabel(parent) : "—")}</td>
|
|
<td>${c.sortOrder}</td>
|
|
<td><span class="badge ${c.active ? "on" : "off"}">${c.active ? "فعال" : "غیرفعال"}</span></td>
|
|
<td>
|
|
<div class="btn-row">
|
|
<button type="button" class="btn btn-ghost" data-edit-cat="${esc(c.slug)}">ویرایش</button>
|
|
<button type="button" class="btn btn-danger" data-del-cat="${esc(c.slug)}">حذف</button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
})
|
|
.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 = `<tr><td colspan="7" style="color:var(--muted)">محصولی نیست.</td></tr>`;
|
|
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 `<tr>
|
|
<td>
|
|
<strong>${esc(p.name)}</strong>
|
|
<div class="mono" style="color:var(--muted)">${esc(p.slug)}</div>
|
|
</td>
|
|
<td>${esc(categoryFaPath(p.category, p.categoryFa))}${
|
|
p.category
|
|
? `<div style="color:var(--muted);font-size:0.75rem;margin-top:0.15rem">${esc(p.category)}</div>`
|
|
: ""
|
|
}</td>
|
|
<td>
|
|
${
|
|
variants.length
|
|
? `<span class="badge on">${variants.length} رنگ</span>
|
|
<div style="color:var(--muted);font-size:0.8rem;margin-top:0.2rem">${esc(shades)}${
|
|
variants.length > 4 ? "…" : ""
|
|
}</div>`
|
|
: `<span class="badge">بدون واریانت</span>`
|
|
}
|
|
</td>
|
|
<td>${formatPrice(p.price)}</td>
|
|
<td>${stock}</td>
|
|
<td><span class="badge ${p.active ? "on" : "off"}">${p.active ? "فعال" : "غیرفعال"}</span></td>
|
|
<td>
|
|
<div class="btn-row">
|
|
<button type="button" class="btn btn-ghost" data-edit-product="${esc(p.id)}">ویرایش</button>
|
|
<button type="button" class="btn btn-danger" data-del-product="${esc(p.id)}">حذف</button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
})
|
|
.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 = `<option value="${esc(selected)}" selected>${esc(selected)}</option>`;
|
|
}
|
|
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(
|
|
`<option value="${esc(c.slug)}" ${
|
|
c.slug === resolved ? "selected" : ""
|
|
}>${esc(pad + label)}</option>`
|
|
);
|
|
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(
|
|
`<option value="${esc(c.slug)}" ${
|
|
c.slug === resolved ? "selected" : ""
|
|
}>${esc(categoryLabel(c))}</option>`
|
|
);
|
|
}
|
|
}
|
|
return extra + lines.join("");
|
|
}
|
|
|
|
function variantRowHtml(v = {}, index = 0) {
|
|
const hex = v.colorHex || "#cccccc";
|
|
return `<div class="variant-card" data-variant-row data-original="${esc(JSON.stringify(v))}">
|
|
<div class="variant-card-top">
|
|
<strong>
|
|
<span class="variant-swatch" style="background:${esc(hex)}"></span>
|
|
واریانت ${index + 1}
|
|
</strong>
|
|
<button type="button" class="btn btn-danger" data-remove-variant>حذف</button>
|
|
</div>
|
|
<input type="hidden" data-v="id" value="${esc(v.id || "")}" />
|
|
<div class="field"><label>نام رسمی واریانت</label><input data-v="name" value="${esc(v.name || "")}" /></div>
|
|
<div class="field"><label>توضیحات فارسی واریانت</label><textarea data-v="description">${esc(v.description || "")}</textarea></div>
|
|
<div class="field"><label>عنوان سئو</label><input data-v="seoTitle" value="${esc(v.seoTitle || "")}" /></div>
|
|
<div class="field"><label>توضیحات سئو</label><textarea data-v="seoDescription">${esc(v.seoDescription || "")}</textarea></div>
|
|
<div class="grid-3">
|
|
<div class="field"><label>شید / رنگ</label><input data-v="shade" required value="${esc(v.shade || "")}" placeholder="01 / Soft Rose" /></div>
|
|
<div class="field"><label>HEX رنگ (اختیاری)</label><input data-v="colorHex" value="${esc(v.colorHex || "")}" placeholder="#aabbcc" pattern="#[0-9a-fA-F]{6}" /></div>
|
|
<div class="field"><label>اسلاگ واریانت</label><input data-v="slug" value="${esc(v.slug || "")}" placeholder="auto" dir="ltr" /></div>
|
|
</div>
|
|
<div class="grid-3">
|
|
<div class="field"><label>قیمت</label><input data-v="price" type="number" step="1" value="${esc(v.price ?? "")}" /></div>
|
|
<div class="field"><label>قیمت قبل</label><input data-v="compareAtPrice" type="number" step="1" value="${esc(v.compareAtPrice ?? "")}" /></div>
|
|
<div class="field"><label>موجودی</label><input data-v="stock" type="number" step="1" value="${esc(v.stock ?? 0)}" /></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="field"><label>SKU</label><input data-v="sku" value="${esc(v.sku || "")}" /></div>
|
|
<div class="field"><label>External ID</label><input data-v="externalId" value="${esc(v.externalId || "")}" /></div>
|
|
</div>
|
|
<div class="field"><label>تصویر واریانت</label><input data-v="heroImage" value="${esc(v.heroImage || "")}" placeholder="/images/..." /></div>
|
|
<div class="field"><label>گالری (با ویرگول)</label><input data-v="images" value="${esc(
|
|
Array.isArray(v.images) ? v.images.join(", ") : ""
|
|
)}" placeholder="/a.jpg, /b.jpg" /></div>
|
|
</div>`;
|
|
}
|
|
|
|
function productForm(p = {}) {
|
|
const variants = Array.isArray(p.colorVariants) ? p.colorVariants : [];
|
|
return `
|
|
<h3>${p.id ? "ویرایش محصول" : "محصول جدید"}</h3>
|
|
<form id="productForm">
|
|
<div class="grid-2">
|
|
<div class="field"><label>نام</label><input name="name" required value="${esc(p.name || "")}" /></div>
|
|
<div class="field"><label>اسلاگ خانواده</label><input name="slug" value="${esc(p.slug || "")}" placeholder="auto" /></div>
|
|
</div>
|
|
<div class="grid-3">
|
|
<div class="field"><label>قیمت پایه</label><input name="price" type="number" step="1" value="${esc(p.price ?? 0)}" /></div>
|
|
<div class="field"><label>قیمت قبل</label><input name="compareAtPrice" type="number" step="1" value="${esc(p.compareAtPrice ?? "")}" /></div>
|
|
<div class="field"><label>موجودی پایه</label><input name="stock" type="number" step="1" value="${esc(p.stock ?? 0)}" title="اگر واریانت داشته باشد، از جمع موجودی واریانتها پر میشود" /></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="field">
|
|
<label>دسته</label>
|
|
<select name="category" data-category-select><option value="">—</option>${categoryOptions(p.category)}</select>
|
|
</div>
|
|
<div class="field"><label>عنوان فارسی دسته</label><input name="categoryFa" data-category-fa value="${esc(p.categoryFa || categoryFaPath(p.category, p.categoryFa) || "")}" /></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="field"><label>SKU</label><input name="sku" value="${esc(p.sku || "")}" /></div>
|
|
<div class="field"><label>External ID</label><input name="externalId" value="${esc(p.externalId || "")}" /></div>
|
|
</div>
|
|
<div class="field"><label>تصویر اصلی</label><input name="heroImage" value="${esc(p.heroImage || "")}" /></div>
|
|
<div class="field"><label>توضیحات</label><textarea name="description">${esc(p.description || "")}</textarea></div>
|
|
<div class="field"><label>عنوان فارسی</label><input name="nameFa" value="${esc(p.nameFa || "")}" /></div>
|
|
<div class="field"><label>عنوان سئو</label><input name="seoTitle" value="${esc(p.seoTitle || "")}" /></div>
|
|
<div class="field"><label>توضیحات سئو</label><textarea name="seoDescription">${esc(p.seoDescription || "")}</textarea></div>
|
|
|
|
<div class="kiko-enrich card" style="margin:1rem 0;padding:1rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.55)">
|
|
<h4 style="margin:0 0 .35rem">دریافت از KIKO / اینترنت</h4>
|
|
<p style="margin:0 0 .75rem;color:var(--muted);font-size:.92rem;line-height:1.7">
|
|
توضیح همین محصول را از صفحه رسمی
|
|
<span class="mono" dir="ltr">kikocosmetics.com</span> میگیرد.
|
|
روی همین محصول اعمال میشود و خانواده تکراری نمیسازد.
|
|
</p>
|
|
<div class="field">
|
|
<label>آدرس صفحه محصول KIKO (ترجیحی)</label>
|
|
<input name="kikoUrl" dir="ltr" placeholder="https://www.kikocosmetics.com/en-gb/p/product-name-12345/" value="${esc(p.sourceUrl || "")}" />
|
|
</div>
|
|
<label class="check" style="margin:.5rem 0 0.75rem;display:flex;gap:.5rem;align-items:flex-start">
|
|
<input type="checkbox" name="overwriteDescription" />
|
|
<span>اگر توضیحات فارسی از قبل هست، با متن KIKO جایگزین شود (پیشفرض: فقط پر کردن توضیح خالی)</span>
|
|
</label>
|
|
<div class="btn-row">
|
|
<button type="button" class="btn" id="fetchKikoBtn">دریافت توضیحات</button>
|
|
${p.id ? `<button type="button" class="btn btn-ghost" id="fetchKikoSaveBtn">دریافت و ذخیره روی همین محصول</button>` : ""}
|
|
</div>
|
|
<p id="kikoEnrichMsg" class="msg info" style="margin:.75rem 0 0"></p>
|
|
</div>
|
|
|
|
<div class="variants-box">
|
|
<div class="variants-head">
|
|
<div>
|
|
<h4>واریانتها (رنگ / شید / …)</h4>
|
|
<p>هر رنگ موجودی، قیمت و تصویر خودش را دارد — مثل فروشگاه کیکو.</p>
|
|
</div>
|
|
<button type="button" class="btn btn-ghost" id="addVariantBtn">+ واریانت</button>
|
|
</div>
|
|
<div class="variant-list" id="variantList">
|
|
${
|
|
variants.length
|
|
? variants.map((v, i) => variantRowHtml(v, i)).join("")
|
|
: `<p class="variant-empty" id="variantEmpty">هنوز واریانتی نیست. برای محصولات رنگی اضافه کنید.</p>`
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<label class="check"><input type="checkbox" name="active" ${p.active === false ? "" : "checked"} /> فعال</label>
|
|
<div class="btn-row">
|
|
<button class="btn" type="submit">ذخیره</button>
|
|
<button class="btn btn-ghost" type="button" id="cancelModal">انصراف</button>
|
|
</div>
|
|
<p id="formMsg" class="msg error hidden"></p>
|
|
</form>`;
|
|
}
|
|
|
|
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 = `<p class="variant-empty" id="variantEmpty">هنوز واریانتی نیست. برای محصولات رنگی اضافه کنید.</p>`;
|
|
}
|
|
};
|
|
|
|
$("#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 `
|
|
<h3>${c.slug ? "ویرایش دسته" : "دسته جدید"}</h3>
|
|
<form id="categoryForm">
|
|
<div class="grid-2">
|
|
<div class="field"><label>اسلاگ</label><input name="slug" required value="${esc(c.slug || "")}" ${c.slug ? "readonly" : ""} /></div>
|
|
<div class="field"><label>ترتیب</label><input name="sortOrder" type="number" value="${esc(c.sortOrder ?? 0)}" /></div>
|
|
</div>
|
|
<div class="grid-2">
|
|
<div class="field"><label>نام انگلیسی</label><input name="name" required value="${esc(c.name || "")}" /></div>
|
|
<div class="field"><label>نام فارسی</label><input name="nameFa" value="${esc(c.nameFa || "")}" /></div>
|
|
</div>
|
|
<div class="field">
|
|
<label>والد</label>
|
|
<select name="parentSlug"><option value="">— ریشه —</option>${categoryOptions(c.parentSlug)}</select>
|
|
</div>
|
|
<label class="check"><input type="checkbox" name="active" ${c.active === false ? "" : "checked"} /> فعال</label>
|
|
<div class="btn-row">
|
|
<button class="btn" type="submit">ذخیره</button>
|
|
<button class="btn btn-ghost" type="button" id="cancelModal">انصراف</button>
|
|
</div>
|
|
<p id="formMsg" class="msg error hidden"></p>
|
|
</form>`;
|
|
}
|
|
|
|
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) => `<option value="${esc(f.id)}">${esc(f.name)}</option>`)
|
|
.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 = "خطا در بارگذاری پنل";
|
|
});
|
|
})();
|