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
+308
@@ -0,0 +1,308 @@
|
||||
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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user