Files

309 lines
11 KiB
JavaScript
Raw Permalink 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 { formatPrice, initShadePicker } from "/js/buy.js?v=pdpFx1";
import { renderProductContent } from "/js/product-content.js?v=content5";
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,
};
}
function escapeHtml(s) {
return String(s ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function displayTitle(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();
}
}
return title;
}
function uniqueImages(list) {
const out = [];
const seen = new Set();
for (const src of list || []) {
if (typeof src !== "string" || !src || seen.has(src)) continue;
seen.add(src);
out.push(src);
}
return out;
}
function variantGalleryImages(v) {
if (!v) return [];
if (Array.isArray(v.images) && v.images.length) {
return uniqueImages(v.images);
}
return v.heroImage ? [v.heroImage] : [];
}
/** Prefer shade gallery; fill from family images so PDP always looks like local multi-photo layout. */
function productGalleryImages(product, variant) {
const variantImgs = variantGalleryImages(variant);
const familyImgs = uniqueImages([
...(product?.heroImage ? [product.heroImage] : []),
...((product?.images || []).filter((x) => typeof x === "string" && x)),
]);
if (variantImgs.length >= 2) return variantImgs;
return uniqueImages([...variantImgs, ...familyImgs]);
}
function inStock(p) {
const variants = p.colorVariants;
if (variants && variants.length > 1) {
return variants.some((v) => Number(v.stock) > 0);
}
return Number(p.stock) > 0;
}
function activeVariant(p) {
const variants = p.colorVariants || [];
const wanted = new URLSearchParams(location.search).get('shade');
return variants.find(v=>wanted && v.shade===wanted) || variants.find((v) => v.slug === productSlugFromPath()) || variants.find(v=>v.id===p.defaultVariantId) || variants.find((v) => v.slug === p.slug) || variants[0] || null;
}
function shadePickerHtml(p) {
const variants = p.colorVariants || [];
if (variants.length < 2) return "";
const active = activeVariant(p);
return `<div class="shade-picker" data-shade-picker>
<p class="shade-picker-label">رنگ: <span data-shade-label>${escapeHtml(active?.shade || "")}</span></p>
<div class="shade-swatches" role="listbox" aria-label="انتخاب رنگ">
${variants
.map((v) => {
const isActive = v.id === active?.id;
const bg = v.heroImage
? `background-image:url('${escapeHtml(v.heroImage)}')`
: "";
const variantTitle = displayTitle({ ...p, slug: v.slug, name: v.name || p.name });
const imgs = productGalleryImages(p, v);
const m = moneyItem(p, v);
const sell = sellPriceFromItem(m);
const sellCompare = sellCompareFromItem(m);
return `<button
type="button"
class="shade-swatch${isActive ? " is-active" : ""}"
data-shade-select
data-id="${escapeHtml(v.id || p.id)}"
data-slug="${escapeHtml(v.slug)}"
data-name="${escapeHtml(variantTitle)}"
data-shade="${escapeHtml(v.shade || "")}"
data-intro="${escapeHtml(v.description || v.descriptionIntro || p.description || p.descriptionIntro || "")}"
data-price="${sell}"
data-compare="${sellCompare ?? ""}"
data-image="${escapeHtml(v.heroImage || "")}"
data-images="${escapeHtml(JSON.stringify(imgs))}"
data-stock="${v.stock ?? 0}"
role="option"
aria-selected="${isActive ? "true" : "false"}"
title="${escapeHtml(v.shade || "")}"
${isActive ? 'aria-current="true"' : ""}
><span class="shade-swatch-img" style="${bg}"></span><span class="shade-swatch-code">${escapeHtml(v.shade || "")}</span></button>`;
})
.join("")}
</div>
</div>`;
}
/** Main image for buy column; remaining shots go in the content gallery. */
function splitProductImages(p, v) {
const images = productGalleryImages(p, v);
const main = v?.heroImage || p.heroImage || images[0] || "";
const rest = [];
let skippedMain = false;
for (const src of images) {
if (!skippedMain && main && src === main) {
skippedMain = true;
continue;
}
rest.push(src);
}
return { main, rest, images };
}
function buyPanelHtml(p) {
const title = displayTitle(p);
const v = activeVariant(p);
const money = moneyItem(p, v);
const price = sellPriceFromItem(money);
const compare = sellCompareFromItem(money);
const discount =
compare && compare > price
? Math.round(((compare - price) / compare) * 100)
: null;
const stock = v?.stock ?? p.stock ?? 0;
const low = p.lowStockThreshold ?? 5;
const { main: hero } = splitProductImages(p, v);
const stocked = Number(stock) > 0;
const heroAlt =
(v && (v.heroImageAlt || v.nameFa || v.name)) ||
p.heroImageAlt ||
title ||
"";
let stockMsg = "";
if (!stocked) stockMsg = "ناموجود";
else if (stock <= low) stockMsg = `موجودی محدود ${stock}`;
const heroBlock = hero
? `<figure class="buy-hero" data-buy-hero>
<img src="${escapeHtml(hero)}" alt="${escapeHtml(heroAlt)}" data-buy-hero-img loading="eager" />
</figure>`
: `<figure class="buy-hero" data-buy-hero hidden>
<img src="" alt="" data-buy-hero-img />
</figure>`;
return `<div class="buy-panel" id="buy" data-buy-panel data-category="${escapeHtml(p.category || "")}" data-category-fa="${escapeHtml(p.categoryFa || "")}" data-low-stock="${low}" data-label-add="افزودن به سبد" data-label-oos="ناموجود" data-label-limited="موجودی محدود" data-label-sold="ناموجود">
<p class="label-caps text-muted">انحصاری</p>
<h1 data-buy-title>${escapeHtml(title)}</h1>
${p.tagline ? `<p class="tagline">${escapeHtml(p.tagline)}</p>` : ""}
<div class="price-row" data-buy-price>
<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>`
: ""
}
</div>
<p class="buy-cta-wrap">
<button
type="button"
class="btn btn-block"
data-add-to-cart
data-product-id="${escapeHtml(v?.id || p.id)}"
data-slug="${escapeHtml(v?.slug || p.slug)}"
data-name="${escapeHtml(title)}"
data-price="${price}"
data-image="${escapeHtml(hero)}"
data-category="${escapeHtml(p.category || "")}"
data-category-fa="${escapeHtml(p.categoryFa || "")}"
${stocked ? "" : "disabled"}
>${stocked ? "افزودن به سبد" : "ناموجود"}</button>
<button type="button" class="btn btn-block btn-share" data-share-product data-share-title="${escapeHtml(title)}" aria-label="اشتراک‌گذاری">اشتراک‌گذاری</button>
</p>
<p class="text-muted buy-stock-msg" data-buy-stock style="font-size:0.875rem">${escapeHtml(stockMsg)}</p>
${heroBlock}
${shadePickerHtml(p)}
<ul class="buy-perks"><li>ضمانت اصالت کالا</li></ul>
</div>`;
}
function galleryImageAlt(src, index, richAlts, fallbackAlt) {
const fromRich =
richAlts.find((x) => x && x.url === src)?.alt ||
(typeof richAlts[index] === "string" ? richAlts[index] : richAlts[index]?.alt);
return fromRich || (index === 0 ? fallbackAlt : `${fallbackAlt} – تصویر ${index + 1}`);
}
/** Extra photos (hero lives under the buy CTAs). Stacked full-width. */
function galleryHtml(p) {
const v = activeVariant(p);
const { rest: images } = splitProductImages(p, v);
if (!images.length) return "";
const richAlts = (v && v.imageAlts) || p.imageAlts || [];
const fallbackAlt =
(v && (v.heroImageAlt || v.nameFa || v.name)) ||
p.heroImageAlt ||
p.nameFa ||
p.name ||
"";
return images
.map((src, i) => {
const alt = galleryImageAlt(src, i + 1, richAlts, fallbackAlt);
return `<figure class="pdp-gallery-shot">
<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" loading="${i === 0 ? "eager" : "lazy"}" />
</figure>`;
})
.join("");
}
function productSlugFromPath() {
const m = location.pathname.match(/\/products\/([^/]+)\/?$/);
return m ? decodeURIComponent(m[1]) : "";
}
/**
* Shared PDP for every catalog product (and any future import/export).
* Routing always serves products/index.html; this hydrates from catalog.dedupe2.json by slug.
*/
export async function initProductPage() {
if (!document.body.hasAttribute("data-product-page")) return;
const slug = productSlugFromPath();
const root = document.getElementById("product-root");
const heroWrap = document.querySelector("[data-pdp-hero-wrap]");
const gallery = document.querySelector("[data-variant-gallery]");
const desc = document.querySelector("[data-pdp-description]");
if (!slug || !root) return;
await ensureEurRate();
let products = [];
try {
const res = await fetch("/data/catalog.dedupe2.json");
if (!res.ok) throw new Error("catalog");
const data = await res.json();
products = data.products || [];
} catch {
root.innerHTML = `<p class="text-muted">بارگذاری محصول ناموفق بود.</p>`;
return;
}
const p = products.find((x) => x.slug === slug || x.colorVariants?.some(v=>v.slug===slug));
if (!p) {
root.innerHTML = `<p class="text-muted">محصول یافت نشد.</p><p><a href="/collection/">بازگشت به فروشگاه</a></p>`;
return;
}
const title = displayTitle(p);
document.title = `${title} | کیکومیکو`;
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) {
metaDesc.setAttribute(
"content",
p.seoDescription || p.tagline || p.description || document.title
);
}
if (gallery) {
gallery.innerHTML = galleryHtml(p);
if (heroWrap) heroWrap.hidden = !gallery.innerHTML;
}
const shadesSlot = document.querySelector("[data-pdp-shades]");
if (shadesSlot) {
shadesSlot.innerHTML = "";
shadesSlot.hidden = true;
}
root.innerHTML = buyPanelHtml(p);
initShadePicker();
renderProductContent(desc, p, activeVariant(p) || {});
root.addEventListener("click", (event) => {
const button = event.target.closest("[data-shade-select]");
if (!button) return;
const variant = p.colorVariants?.find((v) => v.id === button.dataset.id);
if (variant) queueMicrotask(() => renderProductContent(desc, p, variant));
});
}