Files
Kikomiko/kikomiko/dist/js/buy.js
T

952 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { addItem, formatPrice } from "/js/cart.js";
import {
ensureEurRate,
sellCompareFromItem,
sellPriceFromItem,
} from "/js/fx.js?v=fx1";
function moneyItem(product, variant) {
const v = variant || {};
return {
price: v.price ?? product?.price,
compareAtPrice: v.compareAtPrice ?? product?.compareAtPrice,
priceEur: v.priceEur ?? product?.priceEur,
sourcePrice: v.sourcePrice ?? product?.sourcePrice,
sourceCurrency: v.sourceCurrency ?? product?.sourceCurrency,
};
}
import { loveYourEyesPillHtml } from "/js/love-your-eyes.js?v=lyeOff";
export function initBuyButtons() {
document.addEventListener("click", (event) => {
const btn = event.target instanceof Element
? event.target.closest("[data-add-to-cart]")
: null;
if (!(btn instanceof HTMLElement)) return;
if (btn.hasAttribute("disabled")) return;
const productId = btn.getAttribute("data-product-id");
const slug = btn.getAttribute("data-slug");
const name = btn.getAttribute("data-name");
const price = Number(btn.getAttribute("data-price"));
const image = btn.getAttribute("data-image");
const category =
btn.getAttribute("data-category-fa") ||
btn.getAttribute("data-category") ||
btn.closest("[data-category-fa]")?.getAttribute("data-category-fa") ||
btn.closest("[data-category]")?.getAttribute("data-category") ||
null;
if (!productId || !slug || !name || !Number.isFinite(price)) return;
addItem({ productId, slug, name, price, image, category });
const isIcon = btn.classList.contains("card-add-btn");
if (isIcon) {
btn.classList.add("is-added");
window.clearTimeout(Number(btn.getAttribute("data-added-timer") || 0));
const timer = window.setTimeout(() => {
btn.classList.remove("is-added");
}, 1200);
btn.setAttribute("data-added-timer", String(timer));
return;
}
const label = btn.getAttribute("data-label-default") || btn.textContent || "";
if (!btn.getAttribute("data-label-default")) {
btn.setAttribute("data-label-default", label);
}
btn.textContent = "به سبد اضافه شد";
btn.classList.add("is-added");
window.clearTimeout(Number(btn.getAttribute("data-added-timer") || 0));
const timer = window.setTimeout(() => {
btn.textContent = btn.getAttribute("data-label-default") || label;
btn.classList.remove("is-added");
}, 1600);
btn.setAttribute("data-added-timer", String(timer));
});
}
/** Native share on PDP (clipboard fallback). */
export function initShareButtons() {
document.addEventListener("click", async (event) => {
const btn = event.target instanceof Element
? event.target.closest("[data-share-product]")
: null;
if (!(btn instanceof HTMLElement)) return;
const title = btn.getAttribute("data-share-title") || document.title;
const url = location.href;
const defaultLabel = btn.getAttribute("data-label-default") || btn.textContent || "اشتراک‌گذاری";
if (!btn.getAttribute("data-label-default")) {
btn.setAttribute("data-label-default", defaultLabel);
}
try {
if (navigator.share) {
await navigator.share({ title, url });
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
btn.textContent = "لینک کپی شد";
btn.classList.add("is-shared");
window.clearTimeout(Number(btn.getAttribute("data-share-timer") || 0));
const timer = window.setTimeout(() => {
btn.textContent = btn.getAttribute("data-label-default") || defaultLabel;
btn.classList.remove("is-shared");
}, 1600);
btn.setAttribute("data-share-timer", String(timer));
}
} catch {
/* user cancelled share sheet — ignore */
}
});
}
function escapeHtml(s) {
return String(s ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function displayName(p) {
const variants = p.colorVariants;
let title = p.name;
if (variants && variants.length >= 2) {
const shade = variants.find((v) => v.slug === p.slug)?.shade;
if (shade) {
const trimmed = p.name.trim();
if (trimmed.endsWith(shade)) title = trimmed.slice(0, -shade.length).trim();
}
}
const fa = p.categoryFa || null;
if (fa && !title.includes(fa)) return `\u2067${fa} · \u2066${title}\u2069\u2069`;
return title;
}
function shadeDots(p) {
const variants = p.colorVariants;
if (!variants || variants.length < 2) return "";
const shown = variants.slice(0, 5);
const extra = variants.length - shown.length;
return `<div class="shade-dots" role="listbox" aria-label="رنگ">${shown
.map((v) => {
const active = v.slug === p.slug ? " is-active" : "";
const bg = v.heroImage
? `background-image:url('${escapeHtml(v.heroImage)}')`
: "";
const href = `/products/${escapeHtml(v.slug)}/`;
return `<button
type="button"
class="shade-dot${active}"
style="${bg}"
title="${escapeHtml(v.shade)}"
aria-label="${escapeHtml(v.shade)}"
aria-selected="${active ? "true" : "false"}"
data-card-shade
data-slug="${escapeHtml(v.slug)}"
data-image="${escapeHtml(v.heroImage || "")}"
data-price="${sellPriceFromItem(moneyItem(p, v))}"
data-compare="${sellCompareFromItem(moneyItem(p, v)) ?? ""}"
data-href="${href}"
></button>`;
})
.join("")}${
extra > 0
? `<span class="shade-dot-more">+${extra.toLocaleString("fa-IR")} عدد</span>`
: ""
}</div>`;
}
function cardIsInStock(p) {
const variants = p.colorVariants;
if (variants && variants.length > 1) {
return variants.some((v) => Number(v.stock) > 0);
}
return Number(p.stock) > 0;
}
function stockBadge(p) {
if (cardIsInStock(p)) return "";
return `<span class="stock-badge stock-badge--oos">نا موجود</span>`;
}
function cardHtml(p) {
const title = displayName(p);
const href = `/products/${escapeHtml(p.slug)}/`;
const updated = p.updatedAt ? new Date(p.updatedAt).getTime() : 0;
const sell = sellPriceFromItem(moneyItem(p, null));
const sellCompare = sellCompareFromItem(moneyItem(p, null));
const discount =
sellCompare && sellCompare > sell
? Math.round((1 - sell / sellCompare) * 100)
: 0;
let bestDiscount = discount;
for (const v of p.colorVariants || []) {
const vs = sellPriceFromItem(moneyItem(p, v));
const vc = sellCompareFromItem(moneyItem(p, v));
if (vc && vc > vs) {
bestDiscount = Math.max(bestDiscount, Math.round((1 - vs / vc) * 100));
}
}
const bounds = catalogPriceBounds(p);
const priceMin = bounds.min;
const priceMax = bounds.max;
const attrs = [
`data-category="${escapeHtml(p.category || "")}"`,
`data-category-fa="${escapeHtml(p.categoryFa || "")}"`,
`data-price="${sell}"`,
`data-price-min="${priceMin}"`,
`data-price-max="${priceMax}"`,
`data-name="${escapeHtml(p.name)}"`,
`data-updated="${Number.isFinite(updated) ? updated : 0}"`,
`data-rating="${p.ratingAvg || 0}"`,
`data-reviews="${p.reviewCount || 0}"`,
`data-featured="${p.featured ? 1 : 0}"`,
`data-in-stock="${cardIsInStock(p) ? 1 : 0}"`,
`data-discount="${bestDiscount}"`,
].join(" ");
const badge =
bestDiscount >= 1 ? `<span class="offer-badge">−${bestDiscount}%</span>` : "";
const priceHtml =
discount >= 1
? `<p class="price price--offer" data-card-price>
<span class="price-now">${formatPrice(sell)}</span>
<span class="price-was">${formatPrice(sellCompare)}</span>
</p>`
: `<p class="price" data-card-price>${formatPrice(sell)}</p>`;
return `<article class="product-card${bestDiscount >= 1 ? " product-card--offer" : ""}" data-product-card ${attrs}>
<a class="thumb" href="${href}" data-card-link>
${badge}
${stockBadge(p)}
${
p.heroImage
? `<img src="${escapeHtml(p.heroImage)}" alt="${escapeHtml(title)}" loading="lazy" data-card-image />`
: ""
}</a>
<div class="meta">
${loveYourEyesPillHtml(p.slug)}
<p class="name"><a href="${href}" data-card-link>${escapeHtml(title)}</a></p>
${shadeDots(p)}
<div class="card-action-row">
${priceHtml}
<button type="button" class="card-add-btn" data-add-to-cart data-product-id="${escapeHtml(p.id)}" data-slug="${escapeHtml(p.slug)}" data-name="${escapeHtml(title)}" data-price="${sell}" data-image="${escapeHtml(p.heroImage || "")}" data-category="${escapeHtml(p.category || "")}" data-category-fa="${escapeHtml(p.categoryFa || "")}" aria-label="افزودن به سبد خرید">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
</button>
</div>
</div>
</article>`;
}
function catalogPriceBounds(p) {
const prices = [sellPriceFromItem(moneyItem(p, null))];
for (const v of p.colorVariants || []) {
const n = sellPriceFromItem(moneyItem(p, v));
if (Number.isFinite(n)) prices.push(n);
}
return { min: Math.min(...prices), max: Math.max(...prices) };
}
function sortCatalog(products, sort) {
const list = [...products];
switch (sort) {
case "price-asc":
return list.sort(
(a, b) => catalogPriceBounds(a).min - catalogPriceBounds(b).min
);
case "price-desc":
return list.sort(
(a, b) => catalogPriceBounds(b).max - catalogPriceBounds(a).max
);
case "best-rated":
return list.sort((a, b) => (b.ratingAvg || 0) - (a.ratingAvg || 0));
case "most-reviewed":
return list.sort((a, b) => (b.reviewCount || 0) - (a.reviewCount || 0));
case "featured":
return list.sort((a, b) => Number(b.featured) - Number(a.featured));
case "name-asc":
return list.sort((a, b) => a.name.localeCompare(b.name, "fa"));
case "newest":
default:
return list.sort(
(a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)
);
}
}
function parseImagesAttr(raw, fallbackImage) {
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length) {
return parsed.filter((src) => typeof src === "string" && src);
}
} catch {
/* ignore */
}
}
return fallbackImage ? [fallbackImage] : [];
}
function updatePdpDiscountBadge(discount) {
document.querySelectorAll("[data-pdp-discount-badge]").forEach((el) => {
if (!(el instanceof HTMLElement)) return;
if (discount != null && discount >= 1) {
el.textContent = `−${discount}%`;
el.hidden = false;
el.classList.add("offer-badge");
} else {
el.textContent = "";
el.hidden = true;
}
});
}
function pdpGalleryMarkup(images) {
const gallery = images.filter((src) => typeof src === "string" && src);
if (!gallery.length) return "";
return gallery
.map(
(src, i) => `<figure class="pdp-gallery-shot">
<img src="${escapeHtml(src)}" alt="" loading="${i === 0 ? "eager" : "lazy"}" />
</figure>`
)
.join("");
}
function splitMainAndRest(images, hero) {
const all = (images || []).filter((src) => typeof src === "string" && src);
const list = all.length ? all : hero ? [hero] : [];
const main = hero || list[0] || "";
const rest = [];
let skippedMain = false;
for (const src of list) {
if (!skippedMain && main && src === main) {
skippedMain = true;
continue;
}
rest.push(src);
}
return { main, rest };
}
function updateVariantGallery(images, hero) {
const { main, rest } = splitMainAndRest(images, hero);
if (!main && !rest.length) return;
const buyHeroImg = document.querySelector("[data-buy-hero-img]");
const buyHeroWrap = document.querySelector("[data-buy-hero]");
if (buyHeroImg && main) {
buyHeroImg.setAttribute("src", main);
if (buyHeroWrap instanceof HTMLElement) buyHeroWrap.hidden = false;
} else if (buyHeroWrap instanceof HTMLElement) {
buyHeroWrap.hidden = true;
}
const grids = document.querySelectorAll("[data-variant-gallery]");
if (grids.length) {
const html = pdpGalleryMarkup(rest);
grids.forEach((grid) => {
grid.innerHTML = html;
});
const shell = document.querySelector("[data-pdp-hero-wrap]");
if (shell instanceof HTMLElement) shell.hidden = !html;
} else {
const first = document.querySelector(
".pdp-gallery-shot img, .gallery-grid img, .pdp-hero img"
);
if (first) first.setAttribute("src", main || rest[0] || "");
}
const heroImg = document.querySelector(".pdp-hero img");
if (heroImg) heroImg.setAttribute("src", main || rest[0] || "");
}
function applyShadeSelection(btn) {
const panel =
btn.closest("[data-buy-panel]") ||
document.querySelector("[data-buy-panel]");
if (!panel) return;
const id = btn.getAttribute("data-id");
const slug = btn.getAttribute("data-slug");
const name = btn.getAttribute("data-name");
const shade = btn.getAttribute("data-shade") || "";
const intro = btn.getAttribute("data-intro") || "";
const price = Number(btn.getAttribute("data-price"));
const compareRaw = btn.getAttribute("data-compare");
const compare = compareRaw ? Number(compareRaw) : null;
const image = btn.getAttribute("data-image") || "";
const images = parseImagesAttr(btn.getAttribute("data-images"), image);
const stock = Number(btn.getAttribute("data-stock") || 0);
const low = Number(panel.getAttribute("data-low-stock") || 5);
const labelAdd = panel.getAttribute("data-label-add") || "افزودن به سبد";
const labelOos = panel.getAttribute("data-label-oos") || "ناموجود";
const labelLimited = panel.getAttribute("data-label-limited") || "";
const labelSold = panel.getAttribute("data-label-sold") || "";
const shadeRoot =
btn.closest("[data-shade-picker]") ||
document.querySelector("[data-pdp-shades] [data-shade-picker]") ||
panel;
shadeRoot.querySelectorAll("[data-shade-select]").forEach((el) => {
const on = el === btn;
el.classList.toggle("is-active", on);
el.setAttribute("aria-selected", on ? "true" : "false");
if (on) el.setAttribute("aria-current", "true");
else el.removeAttribute("aria-current");
});
const shadeLabel =
shadeRoot.querySelector("[data-shade-label]") ||
panel.querySelector("[data-shade-label]");
if (shadeLabel) shadeLabel.textContent = shade;
const title = panel.querySelector("[data-buy-title]");
if (title && name) title.textContent = name;
const introEl = document.querySelector("[data-product-description-intro]");
if (introEl && intro) introEl.textContent = intro;
const factsShade = document.querySelector('[data-product-fact="shade"]');
if (factsShade) factsShade.textContent = shade;
if (name) document.title = `${name} | کیکومیکو`;
const priceRow = panel.querySelector("[data-buy-price]");
if (priceRow && Number.isFinite(price)) {
const discount =
compare && compare > price
? Math.round(((compare - price) / compare) * 100)
: null;
priceRow.innerHTML = `<span class="now">${escapeHtml(formatPrice(price))}</span>${
discount != null
? `<span class="was">${escapeHtml(formatPrice(compare))}</span><span class="offer-badge offer-badge--buy" data-buy-discount>−${discount}%</span>`
: ""
}`;
updatePdpDiscountBadge(discount);
}
const stockEl = panel.querySelector("[data-buy-stock]");
if (stockEl) {
if (stock <= 0) stockEl.textContent = labelSold;
else if (stock <= low) stockEl.textContent = `${labelLimited} ${stock}`;
else stockEl.textContent = "";
}
const syncCartBtn = (el) => {
if (!(el instanceof HTMLElement)) return;
el.setAttribute("data-product-id", id || "");
el.setAttribute("data-slug", slug || "");
el.setAttribute("data-name", name || "");
el.setAttribute("data-price", String(price));
el.setAttribute("data-image", image);
const catFa =
panel.getAttribute("data-category-fa") ||
panel.getAttribute("data-category") ||
"";
const cat = panel.getAttribute("data-category") || "";
if (catFa) el.setAttribute("data-category-fa", catFa);
if (cat) el.setAttribute("data-category", cat);
if (stock > 0) {
el.removeAttribute("disabled");
if (!el.classList.contains("is-added")) {
el.textContent = labelAdd;
el.setAttribute("data-label-default", labelAdd);
}
} else {
el.setAttribute("disabled", "");
el.textContent = labelOos;
el.setAttribute("data-label-default", labelOos);
}
};
panel.querySelectorAll("[data-add-to-cart]").forEach(syncCartBtn);
document
.querySelectorAll(".sticky-buy [data-add-to-cart]")
.forEach(syncCartBtn);
const stickyName = document.querySelector(".sticky-buy [data-sticky-name]");
const stickyPrice = document.querySelector(".sticky-buy [data-sticky-price]");
if (stickyName && name) stickyName.textContent = name;
if (stickyPrice && Number.isFinite(price))
stickyPrice.textContent = formatPrice(price);
updateVariantGallery(images, image);
if (shade) {
const url = new URL(location.href);
url.searchParams.set("shade", shade);
history.replaceState(
null,
"",
url.pathname + "?" + url.searchParams.toString() + url.hash
);
}
}
export function initShadePicker() {
const picker = document.querySelector("[data-shade-picker]");
if (!picker) return;
picker.querySelectorAll("[data-shade-select]").forEach((btn) => {
btn.addEventListener("click", () => applyShadeSelection(btn));
});
const params = new URLSearchParams(location.search);
const wanted = params.get("shade");
if (!wanted) return;
const match = [...picker.querySelectorAll("[data-shade-select]")].find(
(el) => (el.getAttribute("data-shade") || "") === wanted
);
if (match && !match.classList.contains("is-active")) {
applyShadeSelection(match);
}
}
/** Product-list shade dots: swap card image/price/link without navigating. */
export function initCardShadePickers() {
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const btn = target.closest("[data-card-shade]");
if (!btn) return;
event.preventDefault();
event.stopPropagation();
const card = btn.closest("[data-product-card]");
if (!card) return;
const image = btn.getAttribute("data-image") || "";
const href = btn.getAttribute("data-href") || "";
const price = Number(btn.getAttribute("data-price"));
const compareRaw = btn.getAttribute("data-compare");
const compare = compareRaw ? Number(compareRaw) : null;
card.querySelectorAll("[data-card-shade]").forEach((el) => {
const on = el === btn;
el.classList.toggle("is-active", on);
el.setAttribute("aria-selected", on ? "true" : "false");
});
const img = card.querySelector("[data-card-image]");
if (img && image) img.setAttribute("src", image);
if (href) {
card.querySelectorAll("[data-card-link]").forEach((link) => {
link.setAttribute("href", href);
});
}
const priceEl = card.querySelector("[data-card-price]");
if (priceEl && Number.isFinite(price)) {
card.setAttribute("data-price", String(price));
const addBtn = card.querySelector("[data-add-to-cart]");
if (addBtn) addBtn.setAttribute("data-price", String(price));
const discount =
compare && compare > price
? Math.round(((compare - price) / compare) * 100)
: 0;
if (discount >= 1) {
priceEl.classList.add("price--offer");
priceEl.innerHTML = `<span class="price-now">${escapeHtml(formatPrice(price))}</span><span class="price-was">${escapeHtml(formatPrice(compare))}</span>`;
} else {
priceEl.classList.remove("price--offer");
priceEl.textContent = formatPrice(price);
}
const thumb = card.querySelector(".thumb");
let badge = card.querySelector(".offer-badge");
if (discount >= 1) {
if (!badge && thumb) {
badge = document.createElement("span");
badge.className = "offer-badge";
thumb.prepend(badge);
}
if (badge) badge.textContent = `−${discount}%`;
card.classList.add("product-card--offer");
card.setAttribute("data-discount", String(discount));
} else {
badge?.remove();
card.classList.remove("product-card--offer");
card.setAttribute("data-discount", "0");
}
}
});
}
export function initSortForms() {
const collection = document.querySelector("[data-static-collection]");
document.querySelectorAll("[data-sort-select]").forEach((select) => {
select.addEventListener("change", async () => {
// Client-mode listings sort in-place via filters.js — never hard-navigate.
const listing = select.closest("[data-product-listing]");
if (listing?.getAttribute("data-client-mode") === "1") return;
if (!collection) {
const form = select.closest("form");
if (!(form instanceof HTMLFormElement)) return;
const qs = new URLSearchParams(new FormData(form));
if (qs.get("sort") === "newest") qs.delete("sort");
const hash = form.getAttribute("data-hash");
location.assign(
location.pathname +
(qs.toString() ? `?${qs}` : "") +
(hash ? `#${hash}` : "")
);
return;
}
const grid = collection.querySelector("[data-product-grid]");
const countEl =
collection.querySelector("[data-listing-count]") ||
collection.querySelector("[data-product-count]");
if (!grid) return;
try {
const res = await fetch("/data/catalog.dedupe2.json");
const data = await res.json();
const sorted = sortCatalog(data.products || [], select.value);
const limit = Number(collection.getAttribute("data-home-limit") || 0);
const shown = limit > 0 ? sorted.slice(0, limit) : sorted;
grid.innerHTML = shown.map(cardHtml).join("");
if (countEl) countEl.textContent = shown.length.toLocaleString("fa-IR");
const pill = collection.querySelector("[data-filters-result-count]");
if (pill) pill.textContent = shown.length.toLocaleString("fa-IR");
const hash = select.closest("form")?.getAttribute("data-hash");
if (hash) location.hash = hash;
initCardShadePickers();
} catch {
/* keep existing grid */
}
});
});
}
const ROOT_SLUG_TO_CAT = {
"make-up": "MAKE UP",
"skin-care": "SKIN CARE",
fragrances: "FRAGRANCES",
accessories: "ACCESSORIES",
};
function normalizeCatText(value) {
return String(value || "")
.toUpperCase()
.replace(/[-\s]+/g, " ")
.trim();
}
function pathCategoryNeedle() {
const path = location.pathname;
let m = path.match(/\/category\/([^/]+)\/sub\/([^/]+)/);
if (m) return { root: m[1], leaf: m[2], kind: "sub" };
m = path.match(/\/category\/([^/]+)\/type\/([^/]+)/);
if (m) return { root: m[1], leaf: m[2], kind: "type" };
m = path.match(/\/category\/([^/]+)/);
if (m) return { root: m[1], leaf: "", kind: "root" };
return null;
}
function slugifyCategorySegment(part) {
return String(part || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function categoryPathParts(category) {
return String(category || "")
.split("/")
.map((s) => s.trim())
.filter(Boolean);
}
function matchesCategoryRoot(p, rootSlug) {
const lev1 = ROOT_SLUG_TO_CAT[rootSlug];
if (!lev1) return true;
// Match first path segment only — avoids MAKE-UP BAG matching make-up.
const first = normalizeCatText(categoryPathParts(p.category)[0] || "");
return first === normalizeCatText(lev1);
}
function productMatchesSubSlug(categoryRaw, subSlug) {
if (!subSlug) return true;
const leaf = subSlug.toLowerCase();
const slugParts = categoryPathParts(categoryRaw).map(slugifyCategorySegment);
// Exact segment match (mid or leaf): eyes-make-up, face-skin-care, brushes, …
if (slugParts.includes(leaf)) return true;
// Simple pluralization on the last segment only (brush ↔ brushes).
const last = slugParts[slugParts.length - 1] || "";
if (last === `${leaf}s` || leaf === `${last}s`) return true;
const subNorm = normalizeCatText(subSlug.replace(/-/g, " "));
const catNorm = normalizeCatText(categoryRaw);
// Full sub-label substring (not a short root prefix like "skin care").
if (subNorm.length >= 4 && catNorm.includes(subNorm)) return true;
return false;
}
function productMatchesTypeSlug(p, typeSlug) {
if (!typeSlug) return true;
const parts = categoryPathParts(p.category);
const lastSlug = slugifyCategorySegment(parts[parts.length - 1] || "");
const t = typeSlug.toLowerCase();
if (
lastSlug === t ||
lastSlug.startsWith(`${t}s`) ||
t.startsWith(lastSlug.replace(/s$/, ""))
) {
return true;
}
const name = String(p.name || "").toLowerCase();
const keywords = {
foundation: ["foundation", "skin tone", "skin tint"],
concealer: ["concealer"],
mascara: ["mascara"],
"lip-gloss": ["lip gloss", "lipgloss", "lip oil"],
blush: ["blush"],
"eye-pencil": ["eye pencil", "kajal", "kohl"],
};
const list = keywords[typeSlug];
return Boolean(list?.some((k) => name.includes(k)));
}
function productMatchesListing(p, listing, wrap) {
const mode =
listing?.getAttribute("data-listing-mode") ||
(wrap?.hasAttribute("data-home-limit") ? "home" : "") ||
"";
if (mode === "offers") {
let best = 0;
if (p.compareAtPrice && p.compareAtPrice > p.price) {
best = Math.round((1 - p.price / p.compareAtPrice) * 100);
}
for (const v of p.colorVariants || []) {
if (v.compareAtPrice && v.compareAtPrice > v.price) {
best = Math.max(
best,
Math.round((1 - v.price / v.compareAtPrice) * 100)
);
}
}
return best >= 30;
}
if (mode === "category") {
const pathNeedle = pathCategoryNeedle();
const rootSlug =
listing.getAttribute("data-category-slug") ||
pathNeedle?.root ||
"";
if (!matchesCategoryRoot(p, rootSlug)) return false;
if (pathNeedle?.kind === "type" && pathNeedle.leaf) {
return productMatchesTypeSlug(p, pathNeedle.leaf);
}
const urlLeaf = pathNeedle?.leaf || "";
if (urlLeaf) return productMatchesSubSlug(p.category, urlLeaf);
const subLabel = listing.getAttribute("data-current-sub") || "";
if (subLabel && !/^[a-z0-9-]+$/i.test(subLabel)) {
const labelNorm = normalizeCatText(subLabel);
const catFa = normalizeCatText(p.categoryFa || "");
if (labelNorm && (normalizeCatText(p.category).includes(labelNorm) || catFa.includes(labelNorm))) {
return true;
}
}
return true;
}
return true;
}
/** Empty category exports ship without a grid — inject one before hydration. */
function ensureProductGrid(wrap, listing) {
let grid =
wrap.querySelector?.("[data-product-grid]") ||
listing?.closest?.(".page-pad")?.querySelector?.("[data-product-grid]");
if (grid) return grid;
const pagePad = listing?.closest?.(".page-pad") || wrap.closest?.(".page-pad");
if (!pagePad) return null;
pagePad.querySelectorAll(".container-luxury.section-padding").forEach((el) => {
if (/محصولی در این دسته/.test(el.textContent || "")) el.remove();
});
grid = document.createElement("div");
grid.className = "container-luxury list-grid";
grid.setAttribute("data-product-grid", "");
const guide = pagePad.querySelector(
"section.container-luxury.section-padding, section.section-padding, section.editorial-guide"
);
if (guide) pagePad.insertBefore(grid, guide);
else pagePad.appendChild(grid);
return grid;
}
function megaHrefSubSlug(href) {
const m = String(href || "").match(/\/sub\/([^/]+)/);
return m?.[1]?.toLowerCase() || "";
}
function countProductsForHref(products, href) {
const rootMatch = String(href || "").match(/\/category\/([^/]+)/);
const root = rootMatch?.[1] || "";
const sub = megaHrefSubSlug(href);
return products.filter((p) => {
if (!matchesCategoryRoot(p, root)) return false;
return sub ? productMatchesSubSlug(p.category, sub) : true;
}).length;
}
function faCount(n) {
return `(${n.toLocaleString("fa-IR")})`;
}
/** Fill empty fragrance mega/mobile panes when catalog has FRAGRANCES products. */
function hydrateEmptyFragranceMenus(products) {
const n = products.filter((p) => matchesCategoryRoot(p, "fragrances")).length;
if (n < 1) return;
const countText = faCount(n);
document
.querySelectorAll('.mega-pane[data-mega-pane="fragrances"] .mega-children')
.forEach((ul) => {
const empty = [...ul.querySelectorAll("li.text-muted")].find((li) =>
/هنوز محصولی/.test(li.textContent || "")
);
if (!empty) return;
empty.outerHTML = `<li class="mega-child">
<span class="mega-idx">|01|</span>
<a href="/category/fragrances/">عطر<span class="mega-count">${countText}</span></a>
</li>`;
});
document
.querySelectorAll('.mobile-sub[data-mobile-sub="fragrances"]')
.forEach((sub) => {
if (sub.querySelector("a[href*='/category/fragrances']")) return;
sub.innerHTML = `<a href="/category/fragrances/">عطر <span>${countText}</span></a>`;
const row = sub.closest(".mobile-cat")?.querySelector(".mobile-cat-row");
if (row && !row.querySelector("[data-mobile-expand]")) {
const btn = document.createElement("button");
btn.type = "button";
btn.setAttribute("data-mobile-expand", "fragrances");
btn.setAttribute("aria-label", "زیرمجموعه‌های عطر");
btn.innerHTML =
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';
row.appendChild(btn);
}
});
}
/** Refresh (0) placeholders in the mega + mobile menus from the live catalog. */
function hydrateMegaMenuCounts(products) {
hydrateEmptyFragranceMenus(products);
document.querySelectorAll(".mega-child a[href*='/category/']").forEach((link) => {
if (!(link instanceof HTMLAnchorElement)) return;
const countEl = link.querySelector(".mega-count");
if (!countEl) return;
const n = countProductsForHref(products, link.getAttribute("href"));
countEl.textContent = faCount(n);
});
// Mobile drawer uses bare <span>(0)</span> without .mega-count
document.querySelectorAll(".mobile-sub a[href*='/category/']").forEach((link) => {
if (!(link instanceof HTMLAnchorElement)) return;
const span = link.querySelector("span");
if (!span) return;
const n = countProductsForHref(products, link.getAttribute("href"));
span.textContent = faCount(n);
});
// Filter sheet data-count attrs (used by estimateCount fallback)
document
.querySelectorAll('[data-filter-option="category"][data-count]')
.forEach((btn) => {
if (!(btn instanceof HTMLElement)) return;
const href = btn.getAttribute("data-href") || "";
if (!href.includes("/category/")) return;
const n = countProductsForHref(products, href);
btn.setAttribute("data-count", String(n));
const countSpan = btn.querySelector(".filters-option-count");
if (countSpan) countSpan.textContent = n.toLocaleString("fa-IR");
});
}
/**
* Design-export pages ship empty product grids. Hydrate them from
* /data/catalog.dedupe2.json so the CMS export actually appears.
* Mega-menu counts always refresh — even when grids already have cards
* (homepage) — so (0) placeholders are not left behind after a catalog swap.
*/
export async function initStaticCatalogHydration() {
await ensureEurRate();
const wraps = [
...document.querySelectorAll("[data-static-collection]"),
...document.querySelectorAll(
'[data-product-listing][data-client-mode="1"]'
),
];
const targets = [];
const seen = new Set();
for (const el of wraps) {
const wrap =
el.closest(".collection-wrap") ||
el.closest(".page-pad") ||
el.parentElement ||
el;
let grid =
wrap.querySelector?.("[data-product-grid]") ||
el.querySelector?.("[data-product-grid]");
const listing =
wrap.querySelector?.("[data-product-listing]") ||
el.closest?.("[data-product-listing]") ||
el;
if (!grid && listing?.getAttribute("data-listing-mode") === "category") {
grid = ensureProductGrid(wrap, listing);
}
if (!grid || seen.has(grid)) continue;
if (grid.querySelector("[data-product-card]")) continue;
// Search page has its own loader
if (grid.hasAttribute("data-search-results")) continue;
seen.add(grid);
targets.push({ wrap, grid, listing });
}
let products = [];
try {
const res = await fetch("/data/catalog.dedupe2.json");
if (!res.ok) throw new Error("catalog fetch failed");
const data = await res.json();
products = data.products || [];
} catch {
return;
}
// Always refresh menu counts from the live catalog (homepage included).
hydrateMegaMenuCounts(products);
if (!targets.length) return;
for (const { wrap, grid, listing } of targets) {
const sort =
listing?.getAttribute("data-current-sort") ||
new URLSearchParams(location.search).get("sort") ||
"newest";
let list = products.filter((p) =>
productMatchesListing(p, listing, wrap)
);
list = sortCatalog(list, sort);
const limit = Number(
wrap.getAttribute?.("data-home-limit") ||
listing?.closest?.("[data-home-limit]")?.getAttribute("data-home-limit") ||
0
);
const shown = limit > 0 ? list.slice(0, limit) : list;
grid.innerHTML = shown.map(cardHtml).join("");
const countEl =
listing?.querySelector?.("[data-listing-count]") ||
wrap.querySelector?.("[data-listing-count]");
if (countEl) countEl.textContent = shown.length.toLocaleString("fa-IR");
const pill = listing?.querySelector?.("[data-filters-result-count]");
if (pill) pill.textContent = shown.length.toLocaleString("fa-IR");
}
initCardShadePickers();
}
export { formatPrice };