first deployment of kikomiko with url photos
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8d98304137
commit
ae3ed7c7b7
Vendored
+951
@@ -0,0 +1,951 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user