952 lines
34 KiB
JavaScript
952 lines
34 KiB
JavaScript
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("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
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 };
|