first deployment of kikomiko with url photos

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shayan khoshrou
2026-10-19 19:10:57 +02:00
co-authored by Cursor
parent 8d98304137
commit ae3ed7c7b7
159 changed files with 59509 additions and 0 deletions
+308
View File
@@ -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("&", "&amp;")
.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));
});
}