Files

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 = "خطا در بارگذاری پنل";
});
})();