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
+444
View File
@@ -0,0 +1,444 @@
/**
* Atelier showroom — scroll drives story cards + video scrub on all devices.
* Light MP4, http(s) src only (no blob/fastSeek), seeked + short fallback unlock.
*/
export function initAtelierHero() {
const root = document.querySelector("[data-atelier]");
if (!(root instanceof HTMLElement)) return;
const track = root.querySelector(".atelier-track");
const fill = root.querySelector("[data-atelier-fill]");
const hint = root.querySelector("[data-atelier-hint]");
const stage = root.querySelector(".atelier-stage");
const bg = root.querySelector(".atelier-bg");
const motion = root.querySelector("[data-atelier-motion]");
const video = root.querySelector("[data-atelier-video]");
const allFeats = [...root.querySelectorAll("[data-atelier-feat]")];
const cards = allFeats.filter((el) => el.getAttribute("data-atelier-feat") !== "finale");
const finaleEl = allFeats.find((el) => el.getAttribute("data-atelier-feat") === "finale");
const dots = [...root.querySelectorAll("[data-atelier-dot]")];
if (!(track instanceof HTMLElement) || !cards.length) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
const storyMode = root.hasAttribute("data-atelier-story");
const pairCount = storyMode ? cards.length : Math.ceil(cards.length / 2);
const stepCount = pairCount + (finaleEl ? 1 : 0);
const finaleStep = stepCount - 1;
let active = true;
let raf = 0;
let ticking = false;
let lastStep = -1;
let lastAppliedP = -1;
let hintHidden = false;
let videoReady = false;
let scrubDuration = 0;
let seeking = false;
let seekFallback = 0;
let unlocking = false;
let decoderUnlocked = false;
let targetP = 0;
let smoothP = 0;
let targetTime = 0;
let rootTop = 0;
let trackHeight = 0;
let metricsDirty = true;
let scrollScrub = !reduce;
const SCRUB_END_TRIM_SEC = 0.08;
const SEEK_EPS = coarsePointer ? 0.08 : 0.04;
const VIDEO_SRC = "/videos/kiko-hero-motion-v3.mp4";
const viewportH = () =>
window.visualViewport?.height ?? window.innerHeight;
const refreshDuration = () => {
if (!(video instanceof HTMLVideoElement)) return;
if (!(Number.isFinite(video.duration) && video.duration > 0)) return;
scrubDuration = Math.max(0.5, video.duration - SCRUB_END_TRIM_SEC);
};
const measure = () => {
trackHeight = track.offsetHeight;
rootTop = root.getBoundingClientRect().top;
metricsDirty = false;
};
const lockVideoUi = () => {
if (!(video instanceof HTMLVideoElement)) return;
video.controls = false;
video.removeAttribute("controls");
video.setAttribute("controlslist", "nodownload nofullscreen noremoteplayback");
try {
video.disablePictureInPicture = true;
} catch {
/* ignore */
}
video.muted = true;
video.defaultMuted = true;
video.playsInline = true;
video.loop = false;
video.removeAttribute("autoplay");
video.removeAttribute("loop");
video.setAttribute("muted", "");
video.setAttribute("playsinline", "");
video.setAttribute("webkit-playsinline", "");
video.setAttribute("disablepictureinpicture", "");
// metadata keeps LCP bandwidth free; bump to auto on first scrub gesture
video.preload = "metadata";
};
const preferFullVideoBuffer = () => {
if (!(video instanceof HTMLVideoElement)) return;
if (video.preload === "auto") return;
video.preload = "auto";
};
const clearSeekFallback = () => {
if (seekFallback) {
window.clearTimeout(seekFallback);
seekFallback = 0;
}
};
const unlockDecoder = () => {
if (!(video instanceof HTMLVideoElement) || decoderUnlocked || unlocking) return;
unlocking = true;
const unlock = video.play();
const done = () => {
try {
video.pause();
} catch {
/* ignore */
}
unlocking = false;
decoderUnlocked = true;
refreshDuration();
if (scrollScrub && active) startLoop();
};
if (unlock && typeof unlock.then === "function") {
unlock.then(done).catch(done);
} else {
done();
}
};
const applySeek = () => {
if (!(video instanceof HTMLVideoElement)) return;
if (!scrubDuration || seeking || unlocking) return;
if (Math.abs(video.currentTime - targetTime) < SEEK_EPS) return;
seeking = true;
clearSeekFallback();
seekFallback = window.setTimeout(() => {
seeking = false;
seekFallback = 0;
if (scrollScrub && active) startLoop();
}, coarsePointer ? 260 : 200);
try {
if (!video.paused) video.pause();
video.currentTime = targetTime;
} catch {
seeking = false;
clearSeekFallback();
}
};
const ensureVideo = () => {
if (!(video instanceof HTMLVideoElement)) return;
if (!videoReady) {
lockVideoUi();
videoReady = true;
const src =
video.getAttribute("data-src") ||
video.getAttribute("src") ||
VIDEO_SRC;
if ((video.getAttribute("src") || "") !== src) {
video.src = src;
}
video.load();
}
refreshDuration();
};
const onMeta = () => {
if (!(video instanceof HTMLVideoElement)) return;
refreshDuration();
if (scrubDuration > 0) {
unlockDecoder();
if (scrollScrub && active) startLoop();
}
};
if (video instanceof HTMLVideoElement) {
video.addEventListener("loadedmetadata", onMeta);
video.addEventListener("loadeddata", onMeta);
video.addEventListener("canplay", onMeta);
video.addEventListener("durationchange", refreshDuration);
video.addEventListener("seeked", () => {
seeking = false;
clearSeekFallback();
if (!scrollScrub || !active) return;
if (Math.abs(video.currentTime - targetTime) > SEEK_EPS) applySeek();
else startLoop();
});
video.addEventListener("error", () => {
seeking = false;
clearSeekFallback();
if (!(video instanceof HTMLVideoElement)) return;
video.src = VIDEO_SRC;
video.load();
});
}
const scrubToward = (progress) => {
if (!(video instanceof HTMLVideoElement)) return;
if (!scrubDuration) {
ensureVideo();
if (!scrubDuration) return;
}
const end = Math.max(0.05, scrubDuration - 0.04);
targetTime = Math.min(end, Math.max(0, progress * end));
applySeek();
};
const setStep = (index) => {
const idx = Math.max(0, Math.min(finaleStep, index | 0));
if (idx === lastStep) return;
lastStep = idx;
const isFinale = idx >= pairCount;
root.classList.toggle("is-finale", isFinale);
root.dataset.scene = String(idx);
root.dataset.pair = isFinale ? "finale" : String(idx);
cards.forEach((el, i) => {
const step = storyMode ? i : Math.floor(i / 2);
const on = !isFinale && step === idx;
el.classList.toggle("is-active", on);
el.classList.toggle("is-passed", !isFinale ? step < idx : true);
el.setAttribute("aria-hidden", on ? "false" : "true");
});
if (finaleEl instanceof HTMLElement) {
finaleEl.classList.toggle("is-active", isFinale);
finaleEl.setAttribute("aria-hidden", isFinale ? "false" : "true");
}
dots.forEach((d, i) => {
d.classList.toggle("is-active", i === idx);
d.classList.toggle("is-done", i < idx);
});
if (motion instanceof HTMLElement) {
motion.classList.toggle("is-exit", isFinale);
}
};
setStep(0);
if (reduce) {
scrollScrub = false;
root.classList.add("is-reduced");
track.style.display = "none";
if (video instanceof HTMLVideoElement) {
lockVideoUi();
ensureVideo();
video.pause();
}
return;
}
const applyVisual = (p) => {
const nextStep = Math.min(finaleStep, Math.floor(p * stepCount));
const stepChanged = nextStep !== lastStep;
if (lastAppliedP >= 0 && Math.abs(p - lastAppliedP) < 0.0004 && !stepChanged) {
scrubToward(p);
return;
}
lastAppliedP = p;
if (p > 0.012 && !hintHidden) {
hintHidden = true;
root.classList.add("is-past-hint");
if (hint instanceof HTMLElement) hint.hidden = true;
}
if (fill instanceof HTMLElement) fill.style.transform = `scaleY(${p})`;
if (bg instanceof HTMLElement) bg.style.setProperty("--atelier-p", p.toFixed(4));
if (stage instanceof HTMLElement) {
stage.style.setProperty("--atelier-p", p.toFixed(4));
const isFinale = nextStep >= pairCount;
const exit = isFinale ? 1 : p > 0.86 ? (p - 0.86) / 0.14 : 0;
stage.style.setProperty("--atelier-exit", exit.toFixed(4));
}
setStep(nextStep);
scrubToward(p);
};
const readTarget = () => {
if (metricsDirty) measure();
else rootTop = root.getBoundingClientRect().top;
const scrollable = Math.max(1, trackHeight || root.offsetHeight - viewportH());
const scrolled = Math.min(scrollable, Math.max(0, -rootTop));
return scrolled / scrollable;
};
const tick = () => {
raf = 0;
if (!active) {
ticking = false;
return;
}
targetP = readTarget();
const gap = Math.abs(targetP - smoothP);
const ease = gap > 0.06 ? 0.88 : gap > 0.015 ? 0.68 : 0.48;
smoothP += (targetP - smoothP) * ease;
if (gap < 0.00008) smoothP = targetP;
applyVisual(smoothP);
const needsSeek =
scrollScrub &&
(seeking || Math.abs((video?.currentTime || 0) - targetTime) > SEEK_EPS);
if (Math.abs(targetP - smoothP) > 0.0001 || needsSeek) {
raf = requestAnimationFrame(tick);
} else {
ticking = false;
}
};
const startLoop = () => {
if (!active || !scrollScrub) return;
if (!ticking) {
ticking = true;
raf = requestAnimationFrame(tick);
}
};
const scrollToStep = (index) => {
const idx = Math.max(0, Math.min(finaleStep, index | 0));
metricsDirty = true;
measure();
const scrollable = Math.max(1, trackHeight || root.offsetHeight - viewportH());
const top =
window.scrollY +
root.getBoundingClientRect().top +
((idx + 0.2) / stepCount) * scrollable;
window.scrollTo({ top, behavior: "smooth" });
startLoop();
};
const onUserScroll = () => {
preferFullVideoBuffer();
if (!decoderUnlocked) unlockDecoder();
startLoop();
};
window.addEventListener("scroll", onUserScroll, { passive: true });
window.addEventListener(
"resize",
() => {
metricsDirty = true;
startLoop();
},
{ passive: true }
);
window.visualViewport?.addEventListener(
"resize",
() => {
metricsDirty = true;
startLoop();
},
{ passive: true }
);
window.visualViewport?.addEventListener("scroll", onUserScroll, { passive: true });
window.addEventListener(
"touchstart",
() => {
preferFullVideoBuffer();
ensureVideo();
unlockDecoder();
startLoop();
},
{ passive: true }
);
window.addEventListener("touchmove", onUserScroll, { passive: true });
window.addEventListener("wheel", onUserScroll, { passive: true });
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => {
metricsDirty = true;
startLoop();
});
ro.observe(root);
ro.observe(track);
}
dots.forEach((dot, i) => {
if (!(dot instanceof HTMLElement)) return;
dot.setAttribute("role", "button");
dot.setAttribute("tabindex", "0");
dot.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
scrollToStep(i);
});
});
const io = new IntersectionObserver(
(entries) => {
const e = entries[0];
if (!e) return;
active = e.isIntersecting;
document.documentElement.classList.toggle("is-atelier-scrub", active && scrollScrub);
if (active) {
metricsDirty = true;
ensureVideo();
startLoop();
} else {
if (video instanceof HTMLVideoElement) {
try {
video.pause();
} catch {
/* ignore */
}
}
ticking = false;
if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
}
},
{ rootMargin: "180px 0px", threshold: [0, 0.01, 0.1] }
);
io.observe(root);
if (window.matchMedia("(pointer: fine)").matches) {
cards.forEach((mod) => {
if (!(mod instanceof HTMLElement)) return;
mod.addEventListener("pointermove", (ev) => {
if (!mod.classList.contains("is-active")) return;
const r = mod.getBoundingClientRect();
mod.style.setProperty("--mx", (((ev.clientX - r.left) / r.width - 0.5) * 10).toFixed(2));
mod.style.setProperty("--my", (((ev.clientY - r.top) / r.height - 0.5) * 8).toFixed(2));
});
mod.addEventListener("pointerleave", () => {
mod.style.setProperty("--mx", "0");
mod.style.setProperty("--my", "0");
});
});
}
ensureVideo();
measure();
startLoop();
// Decoder unlock (play/pause) pulls media bytes — wait for first scrub gesture
}
+951
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 };
+89
View File
@@ -0,0 +1,89 @@
import {
hydrateCart,
subscribe,
getSnapshot,
updateQuantity,
removeItem,
formatPrice,
} from "/js/cart.js";
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/** Cart page: items + totals only (checkout is a separate step). */
export function initCartPage() {
const root = document.querySelector("[data-cart-page]");
if (!root) return;
hydrateCart();
const list = root.querySelector("[data-cart-lines]");
const totalEl = root.querySelector("[data-cart-total]");
const checkoutBtn = root.querySelector("[data-cart-checkout]");
const emptyEl = root.querySelector("[data-cart-empty]");
const contentEl = root.querySelector("[data-cart-content]");
function render(snap) {
const empty = !snap.items.length;
if (emptyEl) emptyEl.hidden = !empty;
if (contentEl) contentEl.hidden = empty;
if (checkoutBtn) {
if (empty) checkoutBtn.setAttribute("disabled", "");
else checkoutBtn.removeAttribute("disabled");
}
if (!list || !totalEl) return;
if (empty) {
list.innerHTML = "";
totalEl.textContent = formatPrice(0);
return;
}
list.innerHTML = snap.items
.map(
(item) => `
<div class="cart-line" data-id="${escapeHtml(item.productId)}">
${
item.image
? `<img src="${escapeHtml(item.image)}" alt="" width="72" height="96" />`
: `<div style="width:72px;height:96px;background:#f7f7f7"></div>`
}
<div style="flex:1;min-width:0">
<a href="/products/${escapeHtml(item.slug)}/">${escapeHtml(item.name)}</a>
<div class="text-muted" style="font-size:0.85rem;margin-top:0.25rem">${formatPrice(item.price)}</div>
<div class="qty-controls">
<button type="button" data-qty-dec aria-label="کاهش تعداد">−</button>
<span>${item.quantity}</span>
<button type="button" data-qty-inc aria-label="افزایش تعداد">+</button>
<button type="button" data-qty-remove aria-label="حذف از سبد" style="margin-inline-start:0.5rem;font-size:0.75rem;color:#8a8a8a">حذف</button>
</div>
</div>
</div>`
)
.join("");
totalEl.textContent = formatPrice(snap.subtotal);
list.querySelectorAll(".cart-line").forEach((row) => {
const id = row.getAttribute("data-id");
const item = snap.items.find((i) => i.productId === id);
if (!item || !id) return;
row.querySelector("[data-qty-dec]")?.addEventListener("click", () =>
updateQuantity(id, item.quantity - 1)
);
row.querySelector("[data-qty-inc]")?.addEventListener("click", () =>
updateQuantity(id, item.quantity + 1)
);
row.querySelector("[data-qty-remove]")?.addEventListener("click", () =>
removeItem(id)
);
});
}
subscribe(render);
render(getSnapshot());
}
+101
View File
@@ -0,0 +1,101 @@
/** @typedef {{ productId: string; slug: string; name: string; price: number; image?: string | null; category?: string | null; quantity: number }} CartItem */
const STORAGE_KEY = "kikomiko-cart-v1";
/** @type {CartItem[]} */
let items = [];
let hydrated = false;
const listeners = new Set();
function notify() {
for (const fn of listeners) fn(getSnapshot());
}
function persist() {
if (!hydrated) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch {
/* ignore */
}
}
export function hydrateCart() {
if (hydrated) return;
try {
const raw = localStorage.getItem(STORAGE_KEY);
items = raw ? JSON.parse(raw) : [];
if (!Array.isArray(items)) items = [];
} catch {
items = [];
}
hydrated = true;
notify();
}
export function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
export function getSnapshot() {
const itemCount = items.reduce((n, i) => n + i.quantity, 0);
const subtotal = items.reduce((n, i) => n + i.price * i.quantity, 0);
return { items: [...items], itemCount, subtotal, hydrated };
}
export function addItem(item) {
hydrateCart();
const qty = Math.max(1, item.quantity ?? 1);
const existing = items.find((i) => i.productId === item.productId);
if (existing) {
existing.quantity += qty;
if (item.category && !existing.category) existing.category = item.category;
if (item.image && !existing.image) existing.image = item.image;
} else {
items.push({
productId: item.productId,
slug: item.slug,
name: item.name,
price: item.price,
image: item.image ?? null,
category: item.category ?? null,
quantity: qty,
});
}
persist();
notify();
}
export function updateQuantity(productId, quantity) {
hydrateCart();
if (quantity <= 0) {
items = items.filter((i) => i.productId !== productId);
} else {
const row = items.find((i) => i.productId === productId);
if (row) row.quantity = quantity;
}
persist();
notify();
}
export function removeItem(productId) {
hydrateCart();
items = items.filter((i) => i.productId !== productId);
persist();
notify();
}
export function clearCart() {
hydrateCart();
items = [];
persist();
notify();
}
export function formatPrice(price) {
const amount = Number.isFinite(price) ? Math.round(price) : 0;
return `${new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 0,
}).format(amount)} تومان`;
}
+594
View File
@@ -0,0 +1,594 @@
import {
hydrateCart,
subscribe,
getSnapshot,
updateQuantity,
removeItem,
clearCart,
formatPrice,
} from "/js/cart.js";
import { IRAN_PROVINCES } from "/js/iran-locations.js?v=loc1";
import {
generateInvoicePdf,
downloadFile,
openWhatsAppWithInvoice,
whatsappChatUrl,
} from "/js/invoice-pdf.js?v=eurInv1";
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function generateOrderId() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const n = String(Math.floor(1000 + Math.random() * 9000));
return `KIKO-${y}${m}${day}-${n}`;
}
function ordersEndpoint() {
return (
document.body.dataset.ordersEndpoint ||
window.__KIKOMIKO_ORDERS_URL__ ||
""
).trim();
}
function whatsappNumber() {
return (
document.body.dataset.whatsapp ||
window.__KIKOMIKO_WHATSAPP__ ||
"393520566607"
);
}
function eurToTomanRate() {
const raw = Number(document.body.dataset.eurRate || "");
return Number.isFinite(raw) && raw > 0 ? raw : 0;
}
function googlePlacesKey() {
return (
document.body.dataset.googlePlacesKey ||
window.__GOOGLE_PLACES_API_KEY__ ||
""
).trim();
}
function normalizeFa(s) {
return String(s || "")
.trim()
.replace(/\u200c/g, "")
.replace(/ي/g, "ی")
.replace(/ك/g, "ک")
.replace(/\s+/g, " ");
}
function findProvince(name) {
const n = normalizeFa(name);
if (!n) return null;
return (
IRAN_PROVINCES.find((p) => normalizeFa(p.name) === n) ||
IRAN_PROVINCES.find((p) => normalizeFa(p.name).includes(n) || n.includes(normalizeFa(p.name))) ||
null
);
}
function findCityInProvince(province, cityName) {
if (!province) return null;
const n = normalizeFa(cityName);
if (!n) return null;
return (
province.cities.find((c) => normalizeFa(c) === n) ||
province.cities.find((c) => normalizeFa(c).includes(n) || n.includes(normalizeFa(c))) ||
null
);
}
function receiptItemsHtml(items) {
const rows = items
.map((i) => {
const lineTotal = formatPrice(i.price * i.quantity);
const productType = i.category || "—";
const img = i.image
? `<img class="receipt-thumb" src="${escapeHtml(i.image)}" alt="" width="64" height="85" />`
: `<span class="receipt-thumb receipt-thumb--empty" aria-hidden="true"></span>`;
return `<tr>
<td class="receipt-model">${escapeHtml(productType)}<div class="receipt-sub">${escapeHtml(i.name)}</div></td>
<td class="receipt-qty">${escapeHtml(String(i.quantity))}</td>
<td class="receipt-price">${escapeHtml(lineTotal)}</td>
<td class="receipt-img">${img}</td>
</tr>`;
})
.join("");
return `<div class="receipt-table-wrap">
<table class="receipt-table">
<thead>
<tr>
<th>نوع محصول</th>
<th>عدد</th>
<th>قیمت</th>
<th>عکس</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function fillReceipt(el, data) {
if (!el) return;
const set = (sel, text) => {
const node = el.querySelector(sel);
if (node) node.textContent = text;
};
set("[data-receipt-id]", data.orderId);
set("[data-receipt-date]", data.dateLabel);
set("[data-receipt-name]", data.customerName);
set("[data-receipt-total]", data.totalLabel);
const itemsHost = el.querySelector("[data-receipt-items]");
if (itemsHost) {
if (data.itemsHtml) itemsHost.innerHTML = data.itemsHtml;
else itemsHost.textContent = data.itemsText || "";
}
el.hidden = false;
}
/**
* Province → city cascading selects (Iran). Customers choose; no free text.
*/
function initLocationSelects(form) {
const provinceSelect = form.querySelector("[data-province-select]");
const citySelect = form.querySelector("[data-city-select]");
if (!(provinceSelect instanceof HTMLSelectElement) || !(citySelect instanceof HTMLSelectElement)) {
return { setProvinceCity: () => {} };
}
for (const p of IRAN_PROVINCES) {
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = p.name;
provinceSelect.appendChild(opt);
}
function fillCities(provinceName, preferredCity) {
const province = findProvince(provinceName);
citySelect.innerHTML = "";
if (!province) {
citySelect.disabled = true;
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "ابتدا استان را انتخاب کنید";
citySelect.appendChild(opt);
return;
}
citySelect.disabled = false;
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "انتخاب شهر";
citySelect.appendChild(placeholder);
for (const city of province.cities) {
const opt = document.createElement("option");
opt.value = city;
opt.textContent = city;
citySelect.appendChild(opt);
}
if (preferredCity) {
const matched = findCityInProvince(province, preferredCity);
if (matched) {
citySelect.value = matched;
} else {
const extra = document.createElement("option");
extra.value = preferredCity;
extra.textContent = preferredCity;
citySelect.appendChild(extra);
citySelect.value = preferredCity;
}
}
}
provinceSelect.addEventListener("change", () => {
fillCities(provinceSelect.value, "");
});
function setProvinceCity(provinceName, cityName) {
const province = findProvince(provinceName);
if (!province) {
provinceSelect.value = "";
fillCities("", "");
return;
}
provinceSelect.value = province.name;
fillCities(province.name, cityName || "");
}
return { setProvinceCity, provinceSelect, citySelect };
}
function loadGoogleMapsPlaces(apiKey) {
if (window.google?.maps?.places) return Promise.resolve();
if (window.__kikoGoogleMapsPromise) return window.__kikoGoogleMapsPromise;
window.__kikoGoogleMapsPromise = new Promise((resolve, reject) => {
const cb = `__kikoPlacesReady_${Date.now()}`;
window[cb] = () => {
delete window[cb];
resolve();
};
const script = document.createElement("script");
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&libraries=places&language=fa&region=IR&callback=${cb}`;
script.async = true;
script.onerror = () => reject(new Error("Google Maps failed to load"));
document.head.appendChild(script);
});
return window.__kikoGoogleMapsPromise;
}
/**
* Google Places Autocomplete on address — fills استان/شهر when a place is chosen.
*/
async function initGoogleAddressAutocomplete(form, setProvinceCity) {
const key = googlePlacesKey();
if (!key) return;
const addressEl = form.querySelector('textarea[name="address"], input[name="address"]');
if (!(addressEl instanceof HTMLTextAreaElement) && !(addressEl instanceof HTMLInputElement)) {
return;
}
try {
await loadGoogleMapsPlaces(key);
} catch {
return;
}
// Places Autocomplete needs an <input>; mirror textarea value via a search input above it.
let input = addressEl;
if (addressEl instanceof HTMLTextAreaElement) {
const wrap = document.createElement("label");
wrap.className = "field";
wrap.innerHTML = `<span>جستجوی آدرس (گوگل)</span>`;
input = document.createElement("input");
input.type = "text";
input.setAttribute("autocomplete", "off");
input.setAttribute("placeholder", "شهر، محله یا خیابان را جستجو کنید…");
input.setAttribute("aria-label", "جستجوی آدرس با گوگل");
wrap.appendChild(input);
addressEl.parentElement?.insertBefore(wrap, addressEl);
}
const autocomplete = new google.maps.places.Autocomplete(input, {
componentRestrictions: { country: "ir" },
fields: ["address_components", "formatted_address", "name"],
types: ["geocode"],
});
autocomplete.addListener("place_changed", () => {
const place = autocomplete.getPlace();
if (!place) return;
let province = "";
let city = "";
for (const c of place.address_components || []) {
const types = c.types || [];
if (types.includes("administrative_area_level_1")) {
province = c.long_name;
}
if (
types.includes("locality") ||
types.includes("administrative_area_level_2")
) {
if (!city) city = c.long_name;
}
}
if (province || city) setProvinceCity(province, city);
const formatted = place.formatted_address || place.name || "";
if (formatted && addressEl instanceof HTMLTextAreaElement) {
if (!addressEl.value.trim()) addressEl.value = formatted;
} else if (formatted && addressEl instanceof HTMLInputElement && addressEl === input) {
addressEl.value = formatted;
}
});
}
function validateCheckoutFields(data) {
const firstName = String(data.get("firstName") || "").trim();
const lastName = String(data.get("lastName") || "").trim();
const phone = String(data.get("phone") || "").trim();
const address = String(data.get("address") || "").trim();
const city = String(data.get("city") || "").trim();
const province = String(data.get("province") || "").trim();
if (!firstName) return "نام الزامی است.";
if (!lastName) return "نام خانوادگی الزامی است.";
if (!phone) return "شماره تلفن الزامی است.";
if (phone.replace(/\D/g, "").length < 10) return "شماره تلفن معتبر وارد کنید.";
if (!address) return "آدرس الزامی است.";
if (!province) return "استان را از لیست انتخاب کنید.";
if (!city) return "شهر را از لیست انتخاب کنید.";
return "";
}
/** Static checkout: Sheets storage + WhatsApp (no site backend). */
export function initCheckout() {
const root = document.querySelector("[data-checkout]");
if (!root) return;
hydrateCart();
const list = root.querySelector("[data-cart-lines]");
const totalEl = root.querySelector("[data-cart-total]");
const form = root.querySelector("form");
const errorEl = root.querySelector("[data-checkout-error]");
const successEl = root.querySelector("[data-checkout-success]");
const submitBtn = root.querySelector("[type=submit]");
const receiptEl = root.querySelector("[data-order-receipt]");
const downloadPdfBtn = root.querySelector("[data-receipt-pdf]");
const openWaBtn = root.querySelector("[data-receipt-whatsapp]");
const phone = whatsappNumber();
/** @type {{ file: File|null; waText: string; waUrl: string; pdfUrl?: string } | null} */
let lastInvoice = null;
downloadPdfBtn?.addEventListener("click", () => {
if (lastInvoice?.file) downloadFile(lastInvoice.file);
else if (lastInvoice?.pdfUrl) window.open(lastInvoice.pdfUrl, "_blank", "noopener");
});
openWaBtn?.addEventListener("click", async () => {
if (lastInvoice?.file) {
try {
const result = await openWhatsAppWithInvoice(
lastInvoice.file,
lastInvoice.waText,
phone
);
lastInvoice.pdfUrl = result.pdfUrl;
lastInvoice.waUrl = result.waUrl;
return;
} catch {
/* fall through */
}
}
if (lastInvoice?.waUrl) {
window.location.href = lastInvoice.waUrl;
return;
}
window.location.href = whatsappChatUrl(phone, "");
});
let setProvinceCity = () => {};
if (form instanceof HTMLFormElement) {
const loc = initLocationSelects(form);
setProvinceCity = loc.setProvinceCity;
initGoogleAddressAutocomplete(form, setProvinceCity);
}
function render(snap) {
if (!list || !totalEl || !submitBtn) return;
if (!snap.items.length) {
list.innerHTML = `<p class="empty-cart">سبد خرید خالی است.</p>`;
totalEl.textContent = formatPrice(0);
submitBtn.setAttribute("disabled", "");
return;
}
submitBtn.removeAttribute("disabled");
list.innerHTML = snap.items
.map(
(item) => `
<div class="cart-line" data-id="${item.productId}">
${
item.image
? `<img src="${item.image}" alt="" width="72" height="96" />`
: `<div style="width:72px;height:96px;background:#f7f7f7"></div>`
}
<div style="flex:1;min-width:0">
<a href="/products/${item.slug}/">${escapeHtml(item.name)}</a>
<div class="text-muted" style="font-size:0.85rem;margin-top:0.25rem">${formatPrice(item.price)}</div>
<div class="qty-controls">
<button type="button" data-qty-dec aria-label="کاهش تعداد">−</button>
<span>${item.quantity}</span>
<button type="button" data-qty-inc aria-label="افزایش تعداد">+</button>
<button type="button" data-qty-remove aria-label="حذف از سبد" style="margin-inline-start:0.5rem;font-size:0.75rem;color:#8a8a8a">حذف</button>
</div>
</div>
</div>`
)
.join("");
totalEl.textContent = formatPrice(snap.subtotal);
list.querySelectorAll(".cart-line").forEach((row) => {
const id = row.getAttribute("data-id");
const item = snap.items.find((i) => i.productId === id);
if (!item || !id) return;
row.querySelector("[data-qty-dec]")?.addEventListener("click", () =>
updateQuantity(id, item.quantity - 1)
);
row.querySelector("[data-qty-inc]")?.addEventListener("click", () =>
updateQuantity(id, item.quantity + 1)
);
row.querySelector("[data-qty-remove]")?.addEventListener("click", () =>
removeItem(id)
);
});
}
subscribe(render);
render(getSnapshot());
form?.addEventListener("submit", async (e) => {
e.preventDefault();
if (!(form instanceof HTMLFormElement)) return;
const snap = getSnapshot();
if (!snap.items.length) return;
errorEl && (errorEl.hidden = true);
successEl && (successEl.hidden = true);
const data = new FormData(form);
const validationError = validateCheckoutFields(data);
if (validationError) {
if (errorEl) {
errorEl.hidden = false;
errorEl.textContent = validationError;
}
return;
}
submitBtn?.setAttribute("disabled", "");
if (submitBtn) submitBtn.textContent = "در حال ثبت…";
const customerName = `${String(data.get("firstName") || "").trim()} ${String(data.get("lastName") || "").trim()}`.trim();
const customerPhone = String(data.get("phone") || "").trim();
const email = String(data.get("email") || "").trim();
const address = String(data.get("address") || "").trim();
const city = String(data.get("city") || "").trim();
const province = String(data.get("province") || "").trim();
const notes = String(data.get("notes") || "").trim();
const orderId = generateOrderId();
const dateIso = new Date().toISOString();
const dateLabel = new Date().toLocaleString("fa-IR");
const productsLines = snap.items.map(
(i) =>
`- ${i.name} | ${i.category || "—"} | ×${i.quantity} = ${formatPrice(i.price * i.quantity)}`
);
const itemsText = productsLines.join("\n");
const itemsHtml = receiptItemsHtml(snap.items);
const totalLabel = formatPrice(snap.subtotal);
const endpoint = ordersEndpoint();
if (endpoint) {
try {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "text/plain;charset=utf-8" },
body: JSON.stringify({
orderId,
date: dateIso,
customerName,
phone: customerPhone,
items: itemsText,
total: totalLabel,
status: "Pending",
}),
redirect: "follow",
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok === false) {
throw new Error(json.error || "ثبت سفارش در سیستم ناموفق بود");
}
} catch (err) {
if (errorEl) {
errorEl.hidden = false;
errorEl.textContent =
err instanceof Error ? err.message : "خطا در ثبت سفارش";
}
if (submitBtn) {
submitBtn.textContent = "ثبت سفارش از طریق واتساپ";
submitBtn.removeAttribute("disabled");
}
return;
}
}
fillReceipt(receiptEl, {
orderId,
dateLabel,
customerName,
itemsHtml,
totalLabel,
});
if (successEl) {
successEl.hidden = false;
successEl.textContent = `سفارش ${orderId} ثبت شد. در حال ساخت فاکتور و باز کردن واتساپ…`;
}
const waLines = [
"Order Number:",
orderId,
"",
"Customer Name:",
customerName,
"",
"Products:",
...productsLines,
"",
"Total:",
totalLabel,
"",
`Email: ${email}`,
`Phone: ${customerPhone}`,
`Address: ${address}`,
`City: ${city}`,
`Province: ${province}`,
`Notes: ${notes}`,
];
const waText = waLines.join("\n");
const itemsForPdf = snap.items.map((i) => ({ ...i }));
try {
const pdfFile = await generateInvoicePdf({
orderId,
dateLabel,
customerName,
customerPhone,
address,
city,
province,
items: itemsForPdf,
totalLabel,
formatPrice,
eurToTomanRate: eurToTomanRate(),
});
clearCart();
form.reset();
setProvinceCity("", "");
if (submitBtn) {
submitBtn.textContent = "ثبت سفارش از طریق واتساپ";
submitBtn.setAttribute("disabled", "");
}
if (successEl) {
successEl.textContent =
"فاکتور آماده شد — واتساپ باز می‌شود. فقط دکمه ارسال را بزنید.";
}
const result = await openWhatsAppWithInvoice(pdfFile, waText, phone);
lastInvoice = {
file: pdfFile,
waText,
waUrl: result.waUrl,
pdfUrl: result.pdfUrl,
};
} catch (err) {
clearCart();
form.reset();
setProvinceCity("", "");
if (submitBtn) {
submitBtn.textContent = "ثبت سفارش از طریق واتساپ";
submitBtn.setAttribute("disabled", "");
}
const url = whatsappChatUrl(phone, waText);
lastInvoice = { file: null, waText, waUrl: url };
if (successEl) {
successEl.textContent =
err instanceof Error
? `ثبت شد، اما فاکتور آماده نشد: ${err.message}. واتساپ با متن سفارش باز می‌شود.`
: "ثبت شد. واتساپ با متن سفارش باز می‌شود.";
}
window.setTimeout(() => {
window.location.href = url;
}, 500);
}
});
}
+136
View File
@@ -0,0 +1,136 @@
/**
* Collaboration framed carousel — dots + arrows (no pause/progress bars).
* Distinct from the full-bleed promo hero under the atelier section.
*/
export function initCollabBanners() {
const root = document.querySelector("[data-collab-banners]");
if (!(root instanceof HTMLElement)) return;
const slides = [...root.querySelectorAll("[data-collab-slide]")];
const dots = [...root.querySelectorAll("[data-collab-goto]")];
const nextBtn = root.querySelector("[data-collab-next]");
const prevBtn = root.querySelector("[data-collab-prev]");
if (slides.length < 1) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let index = 0;
let timer = 0;
let paused = reduce;
const durationOf = (i) => {
const raw = Number(slides[i]?.getAttribute("data-cbs-duration") || 7000);
return Number.isFinite(raw) && raw > 0 ? raw : 7000;
};
const show = (next) => {
const i = ((next % slides.length) + slides.length) % slides.length;
index = i;
slides.forEach((slide, si) => {
const on = si === i;
slide.classList.toggle("is-active", on);
slide.setAttribute("aria-hidden", on ? "false" : "true");
});
dots.forEach((dot, di) => {
const on = di === i;
dot.classList.toggle("is-active", on);
dot.setAttribute("aria-current", on ? "true" : "false");
});
root.dataset.active = String(i);
};
const stop = () => {
window.clearInterval(timer);
timer = 0;
};
const start = () => {
stop();
if (paused || reduce || slides.length < 2) return;
timer = window.setInterval(() => {
show(index + 1);
}, durationOf(index));
};
const goTo = (i) => {
show(i);
start();
};
dots.forEach((dot) => {
dot.addEventListener("click", () => {
const i = Number(dot.getAttribute("data-collab-goto"));
if (!Number.isFinite(i)) return;
goTo(i);
});
});
nextBtn?.addEventListener("click", () => goTo(index + 1));
prevBtn?.addEventListener("click", () => goTo(index - 1));
root.addEventListener("mouseenter", () => {
paused = true;
stop();
});
root.addEventListener("mouseleave", () => {
paused = reduce;
start();
});
root.addEventListener("focusin", () => {
paused = true;
stop();
});
root.addEventListener("focusout", (e) => {
if (e.relatedTarget instanceof Node && root.contains(e.relatedTarget)) return;
paused = reduce;
start();
});
let touchX = 0;
let locked = "";
root.addEventListener(
"touchstart",
(e) => {
const t = e.changedTouches[0];
if (!t) return;
touchX = t.clientX;
locked = "";
},
{ passive: true }
);
root.addEventListener(
"touchmove",
(e) => {
const t = e.changedTouches[0];
if (!t || locked === "y") return;
const dx = t.clientX - touchX;
if (!locked && Math.abs(dx) > 12) locked = "x";
},
{ passive: true }
);
root.addEventListener(
"touchend",
(e) => {
const t = e.changedTouches[0];
if (!t || locked !== "x") return;
const dx = t.clientX - touchX;
if (Math.abs(dx) < 48) return;
if (dx < 0) goTo(index + 1);
else goTo(index - 1);
},
{ passive: true }
);
const io = new IntersectionObserver(
(entries) => {
const e = entries[0];
if (!e) return;
if (!e.isIntersecting) stop();
else if (!paused) start();
},
{ threshold: 0.25 }
);
io.observe(root);
show(0);
if (!reduce) start();
}
+460
View File
@@ -0,0 +1,460 @@
/** KIKO-style product listing filters overlay */
function normalizePath(href) {
try {
const u = new URL(href, location.origin);
let p = u.pathname.replace(/\/+$/, "") || "/";
return p;
} catch {
return String(href || "").replace(/\/+$/, "") || "/";
}
}
function pathsMatch(a, b) {
return normalizePath(a) === normalizePath(b);
}
function faCount(n) {
return Number(n || 0).toLocaleString("fa-IR");
}
function findGrid(root) {
const section =
root.closest(".collection-wrap") ||
root.closest(".page-pad") ||
root.parentElement;
if (!section) return null;
return (
section.querySelector("[data-product-grid]") ||
section.querySelector(".list-grid") ||
section.querySelector(".product-grid")
);
}
function cardSortValue(card, sort) {
switch (sort) {
case "price-asc":
// Prefer lowest shade/family price so "ارزان‌ترین" matches what shoppers can pay.
return Number(
card.getAttribute("data-price-min") ||
card.getAttribute("data-price") ||
0
);
case "price-desc":
return -Number(
card.getAttribute("data-price-max") ||
card.getAttribute("data-price") ||
0
);
case "best-rated":
return -Number(card.getAttribute("data-rating") || 0);
case "most-reviewed":
return -Number(card.getAttribute("data-reviews") || 0);
case "featured":
return -Number(card.getAttribute("data-featured") || 0);
case "name-asc":
return (card.getAttribute("data-name") || "").toLowerCase();
case "newest":
default:
return -Number(card.getAttribute("data-updated") || 0);
}
}
function sortCards(cards, sort) {
return [...cards].sort((a, b) => {
const av = cardSortValue(a, sort);
const bv = cardSortValue(b, sort);
if (typeof av === "string" && typeof bv === "string") {
return av.localeCompare(bv, "fa");
}
if (av !== bv) return av < bv ? -1 : 1;
// tie-break: newest
return (
Number(b.getAttribute("data-updated") || 0) -
Number(a.getAttribute("data-updated") || 0)
);
});
}
function categoryNeedleFromOption(btn) {
const sub = btn.getAttribute("data-sub") || "";
const label = btn.getAttribute("data-label") || "";
const root = btn.getAttribute("data-root") || "";
return { sub, label, root, href: btn.getAttribute("data-href") || "" };
}
function normalizeCatText(value) {
return String(value || "")
.toUpperCase()
.replace(/[-\s]+/g, " ")
.trim();
}
const ROOT_SLUG_TO_CAT = {
"make-up": "MAKE UP",
"skin-care": "SKIN CARE",
fragrances: "FRAGRANCES",
accessories: "ACCESSORIES",
};
function rootSlugFromNeedle(needle) {
if (needle?.root) return String(needle.root).toLowerCase();
const href = needle?.href || "";
const m = href.match(/\/category\/([^/?#]+)/);
return m?.[1]?.toLowerCase() || "";
}
function cardMatchesCategory(card, needle) {
if (!needle || (!needle.sub && !needle.root && !needle.label && !needle.href)) {
return true;
}
const cat = card.getAttribute("data-category") || "";
const catNorm = normalizeCatText(cat);
const catFa = normalizeCatText(card.getAttribute("data-category-fa") || "");
if (needle.sub) {
const subNorm = normalizeCatText(needle.sub.replace(/-/g, " "));
const labelNorm = normalizeCatText(needle.label || "");
if (subNorm && catNorm.includes(subNorm)) return true;
if (labelNorm && (catNorm.includes(labelNorm) || catFa.includes(labelNorm))) {
return true;
}
// slug-style match against path segments (eyes-make-up ↔ EYES MAKE-UP)
const parts = cat.split("/").map((p) =>
p
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
);
if (parts.includes(needle.sub.toLowerCase())) return true;
return false;
}
const slug = rootSlugFromNeedle(needle);
const lev1 = slug ? ROOT_SLUG_TO_CAT[slug] : null;
if (lev1) {
const first = normalizeCatText(
cat
.split("/")
.map((p) => p.trim())
.filter(Boolean)[0] || ""
);
if (first === normalizeCatText(lev1)) return true;
}
if (needle.label) {
const labelNorm = normalizeCatText(needle.label);
if (catNorm.includes(labelNorm) || catFa.includes(labelNorm)) return true;
}
return false;
}
function getDraft(root) {
const catBtn = root.querySelector(
'[data-filter-group="category"] .filters-option.is-selected'
);
const sortBtn = root.querySelector(
'[data-filter-group="sort"] .filters-option.is-selected'
);
const stockBtn = root.querySelector(
'[data-filter-option="stock"].is-selected'
);
return {
category: catBtn ? categoryNeedleFromOption(catBtn) : null,
sort: sortBtn?.getAttribute("data-value") || root.getAttribute("data-current-sort") || "newest",
inStock: Boolean(stockBtn),
};
}
function estimateCount(root, draft) {
const grid = findGrid(root);
if (!grid) {
const catBtn = root.querySelector(
'[data-filter-group="category"] .filters-option.is-selected'
);
const c = catBtn?.getAttribute("data-count");
if (c && Number(c) > 0) return Number(c);
return Number(
root.querySelector("[data-listing-count]")?.textContent?.replace(/\D/g, "") ||
0
);
}
const cards = [...grid.querySelectorAll("[data-product-card]")];
let list = cards;
if (draft.category?.href && !pathsMatch(draft.category.href, location.pathname)) {
// Navigating away — prefer embedded count
const catBtn = root.querySelector(
'[data-filter-group="category"] .filters-option.is-selected'
);
const c = Number(catBtn?.getAttribute("data-count") || 0);
if (c > 0) return c;
}
if (draft.category) {
const samePath = pathsMatch(draft.category.href || "", location.pathname);
if (samePath) {
if (draft.category.sub) {
list = list.filter((card) => cardMatchesCategory(card, draft.category));
}
} else {
list = list.filter((card) => cardMatchesCategory(card, draft.category));
}
}
if (draft.inStock) {
list = list.filter((c) => c.getAttribute("data-in-stock") === "1");
}
return list.length;
}
function updateResultPill(root) {
const draft = getDraft(root);
const n = estimateCount(root, draft);
const pill = root.querySelector("[data-filters-result-count]");
if (pill) pill.textContent = faCount(n);
}
function openOverlay(root) {
const overlay = root.querySelector("[data-filters-overlay]");
if (!overlay) return;
overlay.hidden = false;
document.body.classList.add("filters-open");
updateResultPill(root);
const closeBtn = overlay.querySelector(".filters-sheet-close");
if (closeBtn instanceof HTMLElement) closeBtn.focus();
}
function closeOverlay(root) {
const overlay = root.querySelector("[data-filters-overlay]");
if (!overlay) return;
overlay.hidden = true;
document.body.classList.remove("filters-open");
}
function clearDraft(root) {
root
.querySelectorAll("[data-filter-option].is-selected")
.forEach((el) => el.classList.remove("is-selected"));
// Restore default sort = newest
const newest = root.querySelector(
'[data-filter-option="sort"][data-value="newest"]'
);
newest?.classList.add("is-selected");
// On category page, re-select current category "all" if present
const mode = root.getAttribute("data-listing-mode");
if (mode === "category") {
const first = root.querySelector(
'[data-filter-group="category"] .filters-option'
);
first?.classList.add("is-selected");
}
updateResultPill(root);
}
function applyClientFilters(root, draft) {
const grid = findGrid(root);
if (!grid) return 0;
const cards = [...grid.querySelectorAll("[data-product-card]")];
const mode = root.getAttribute("data-listing-mode") || "";
const multiCategory =
mode === "offers" ||
mode === "search" ||
mode === "collection" ||
mode === "home";
cards.forEach((card) => {
let show = true;
if (draft.category) {
const samePath = pathsMatch(draft.category.href || "", location.pathname);
if (samePath) {
// Listing HTML is already scoped to this URL. Only narrow further when
// a subcategory is selected (e.g. in-place filter). Never re-match the
// Persian root label against English data-category — that hid every card.
if (draft.category.sub) {
show = cardMatchesCategory(card, draft.category);
}
} else if (multiCategory) {
// On multi-category pages, filter in place when possible
show = cardMatchesCategory(card, draft.category);
}
}
if (draft.inStock && card.getAttribute("data-in-stock") !== "1") {
show = false;
}
card.hidden = !show;
card.classList.toggle("is-filter-hidden", !show);
});
const visible = cards.filter((c) => !c.hidden);
const sorted = sortCards(visible, draft.sort);
sorted.forEach((card) => grid.appendChild(card));
const countEl = root.querySelector("[data-listing-count]");
if (countEl) countEl.textContent = faCount(sorted.length);
// Keep legacy select in sync without firing change — that handler
// navigates/reloads and was breaking client-side price sorting.
const select = root.querySelector("[data-sort-select]");
if (select instanceof HTMLSelectElement && select.value !== draft.sort) {
select.value = draft.sort;
}
root.setAttribute("data-current-sort", draft.sort || "newest");
return sorted.length;
}
function buildNavUrl(href, draft, root) {
const url = new URL(href, location.origin);
const client = root.getAttribute("data-client-mode") === "1";
if (!client && draft.sort && draft.sort !== "newest") {
url.searchParams.set("sort", draft.sort);
} else if (!client) {
url.searchParams.delete("sort");
}
try {
const preserve = JSON.parse(root.getAttribute("data-preserve") || "{}");
Object.entries(preserve).forEach(([k, v]) => {
// Don't carry search query onto category pages
if (k === "q") return;
if (v) url.searchParams.set(k, String(v));
});
} catch {
/* ignore */
}
const hash = root.getAttribute("data-hash");
// Only keep in-page hash (e.g. #collection) when staying on the same path
const keepHash = Boolean(hash) && pathsMatch(url.pathname, location.pathname);
return url.pathname + url.search + (keepHash ? `#${hash}` : "");
}
function applyFilters(root) {
const draft = getDraft(root);
const mode = root.getAttribute("data-listing-mode") || "";
const client = root.getAttribute("data-client-mode") === "1";
// Always open the category page when a different category is chosen.
// Search/home used to filter the current (often empty) grid in place and
// showed "no results" even when the category had products.
if (
draft.category?.href &&
!pathsMatch(draft.category.href, location.pathname)
) {
location.assign(buildNavUrl(draft.category.href, draft, root));
return;
}
// Server mode without client: sync sort via form submit when only sort changed
if (!client && mode !== "offers" && mode !== "search" && mode !== "home") {
const select = root.querySelector("[data-sort-select]");
if (select instanceof HTMLSelectElement) {
const onlySort =
(!draft.category || pathsMatch(draft.category.href || "", location.pathname)) &&
!draft.inStock;
if (onlySort && select.value !== draft.sort) {
select.value = draft.sort;
const form = select.closest("form");
if (form instanceof HTMLFormElement) {
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;
}
}
}
}
applyClientFilters(root, draft);
// Update URL sort param for shareability
try {
const url = new URL(location.href);
if (draft.sort && draft.sort !== "newest") url.searchParams.set("sort", draft.sort);
else url.searchParams.delete("sort");
history.replaceState(null, "", url.pathname + url.search + url.hash);
} catch {
/* ignore */
}
root.setAttribute("data-current-sort", draft.sort);
closeOverlay(root);
}
function bindListing(root) {
if (root.getAttribute("data-filters-bound") === "1") return;
root.setAttribute("data-filters-bound", "1");
root.querySelector("[data-filters-open]")?.addEventListener("click", () => {
openOverlay(root);
});
root.querySelectorAll("[data-filters-close]").forEach((el) => {
el.addEventListener("click", () => closeOverlay(root));
});
root.querySelector("[data-filters-clear]")?.addEventListener("click", () => {
clearDraft(root);
});
root.querySelector("[data-filters-apply]")?.addEventListener("click", () => {
applyFilters(root);
});
root.querySelectorAll("[data-filter-option]").forEach((btn) => {
btn.addEventListener("click", () => {
const group = btn.getAttribute("data-filter-option");
if (group === "stock") {
btn.classList.toggle("is-selected");
} else if (group === "category" || group === "sort") {
const panel = btn.closest("[data-filter-group]");
panel
?.querySelectorAll("[data-filter-option].is-selected")
.forEach((el) => el.classList.remove("is-selected"));
btn.classList.add("is-selected");
}
updateResultPill(root);
});
});
// Esc to close
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const overlay = root.querySelector("[data-filters-overlay]");
if (overlay && !overlay.hidden) closeOverlay(root);
});
// Apply sort from URL on load (static)
const params = new URLSearchParams(location.search);
const urlSort = params.get("sort");
if (urlSort) {
const sortBtn = root.querySelector(
`[data-filter-option="sort"][data-value="${urlSort.replace(/"/g, "")}"]`
);
if (sortBtn) {
root
.querySelectorAll('[data-filter-option="sort"].is-selected')
.forEach((el) => el.classList.remove("is-selected"));
sortBtn.classList.add("is-selected");
const client = root.getAttribute("data-client-mode") === "1";
if (client || root.getAttribute("data-listing-mode") === "offers") {
applyClientFilters(root, getDraft(root));
}
}
}
}
export function initProductFilters() {
document.querySelectorAll("[data-product-listing]").forEach((root) => {
if (root instanceof HTMLElement) bindListing(root);
});
}
/** Re-bind after dynamic search results render */
export function refreshProductFilters() {
document.querySelectorAll("[data-product-listing]").forEach((root) => {
if (!(root instanceof HTMLElement)) return;
root.removeAttribute("data-filters-bound");
bindListing(root);
updateResultPill(root);
});
}
+52
View File
@@ -0,0 +1,52 @@
import { ensureEurRate } from "/js/fx.js?v=fx1";
function ensureFooterEurRate() {
const enamad = document.querySelector(".footer-enamad");
if (!enamad) return;
let el = document.querySelector("[data-footer-eur-rate]");
if (!el) {
el = document.createElement("p");
el.className = "footer-eur-live";
el.setAttribute("data-footer-eur-rate", "");
el.innerHTML =
'نرخ یورو (بازار آزاد): <strong data-eur-rate-display>…</strong> تومان';
enamad.insertAdjacentElement("afterend", el);
} else if (!el.querySelector("[data-eur-rate-display]")) {
el.innerHTML =
'نرخ یورو (بازار آزاد): <strong data-eur-rate-display>…</strong> تومان';
}
void ensureEurRate();
}
export function initFooter() {
const cols = document.querySelectorAll("[data-footer-col]");
if (cols.length) {
const mq = window.matchMedia("(min-width: 768px)");
const sync = () => {
cols.forEach((el) => {
if (mq.matches) el.setAttribute("open", "");
else el.removeAttribute("open");
});
};
sync();
mq.addEventListener("change", sync);
}
ensureFooterEurRate();
const form = document.querySelector("[data-footer-newsletter]");
if (!form) return;
const status = form.querySelector("[data-footer-newsletter-status]");
form.addEventListener("submit", (event) => {
event.preventDefault();
const input = form.querySelector('input[type="email"]');
if (!(input instanceof HTMLInputElement) || !input.value.trim()) return;
if (status instanceof HTMLElement) {
status.hidden = false;
status.textContent = "ثبت شد — ممنون از عضویت شما.";
}
input.value = "";
});
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Live EUR→toman pricing (TGJU free-market EUR).
* Markups: <3€ +100%, 3–5€ +80%, >5€ +65%.
*/
const FALLBACK_EUR_TOMAN = 302700;
const EUR_DETECT_MAX = 500;
const CACHE_KEY = "kikomiko-eur-toman-v1";
const CACHE_TTL_MS = 5 * 60 * 1000;
let cachedRate = 0;
let inflight = null;
export function markupForEuro(eur) {
if (!Number.isFinite(eur) || eur <= 0) return 1;
if (eur < 3) return 2;
if (eur <= 5) return 1.8;
return 1.65;
}
function parseTgjuRial(raw) {
const n = Number(String(raw ?? "").replace(/,/g, "").trim());
return Number.isFinite(n) && n > 0 ? n : 0;
}
function rialToToman(rial) {
return rial > 0 ? rial / 10 : 0;
}
function readBodyFallback() {
const raw = Number(document.body?.dataset?.eurRate || "");
return Number.isFinite(raw) && raw > 0 ? raw : FALLBACK_EUR_TOMAN;
}
function readSessionCache() {
try {
const raw = sessionStorage.getItem(CACHE_KEY);
if (!raw) return 0;
const parsed = JSON.parse(raw);
if (
parsed &&
Number(parsed.rate) > 0 &&
Date.now() - Number(parsed.at || 0) < CACHE_TTL_MS
) {
return Number(parsed.rate);
}
} catch {
/* ignore */
}
return 0;
}
function writeSessionCache(rate) {
try {
sessionStorage.setItem(
CACHE_KEY,
JSON.stringify({ rate, at: Date.now() })
);
} catch {
/* ignore */
}
}
function applyRateToDom(rate) {
if (!(rate > 0) || !document.body) return;
document.body.dataset.eurRate = String(Math.round(rate));
document.querySelectorAll("[data-eur-rate-display]").forEach((el) => {
el.textContent = new Intl.NumberFormat("fa-IR", {
maximumFractionDigits: 0,
}).format(Math.round(rate));
});
}
/** Prefer same-origin proxy (avoids CORS); fall back to TGJU ajax JSON. */
async function fetchTgjuEurRial() {
const endpoints = ["/api/fx/eur", "https://call1.tgju.org/ajax.json"];
let lastErr;
for (const url of endpoints) {
try {
const res = await fetch(url, {
credentials: "omit",
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`FX HTTP ${res.status}`);
const data = await res.json();
const row = data?.current?.price_eur || data?.price_eur;
const rial = parseTgjuRial(row?.p ?? row);
if (rial > 0) return rial;
throw new Error("TGJU price_eur missing");
} catch (err) {
lastErr = err;
}
}
throw lastErr || new Error("FX fetch failed");
}
export function getEurToTomanRate() {
if (cachedRate > 0) return cachedRate;
const fromSession = readSessionCache();
if (fromSession > 0) {
cachedRate = fromSession;
return cachedRate;
}
return readBodyFallback();
}
export async function ensureEurRate() {
if (cachedRate > 0) return cachedRate;
const fromSession = readSessionCache();
if (fromSession > 0) {
cachedRate = fromSession;
applyRateToDom(cachedRate);
return cachedRate;
}
if (!inflight) {
inflight = fetchTgjuEurRial()
.then((rial) => {
const toman = rialToToman(rial);
cachedRate = toman > 0 ? toman : readBodyFallback();
writeSessionCache(cachedRate);
applyRateToDom(cachedRate);
return cachedRate;
})
.catch(() => {
cachedRate = readBodyFallback();
applyRateToDom(cachedRate);
return cachedRate;
})
.finally(() => {
inflight = null;
});
}
return inflight;
}
export function euroBase(item) {
if (!item) return null;
for (const candidate of [item.priceEur, item.sourcePrice]) {
const n = Number(candidate);
if (Number.isFinite(n) && n > 0 && n < EUR_DETECT_MAX) return n;
}
const cur = String(item.sourceCurrency || "").toUpperCase();
const price = Number(item.price);
if (
(cur === "EUR" || cur === "EU" || cur === "€") &&
Number.isFinite(price) &&
price > 0 &&
price < EUR_DETECT_MAX
) {
return price;
}
return null;
}
export function eurToTomanSell(eur, rate = getEurToTomanRate()) {
const r = rate > 0 ? rate : FALLBACK_EUR_TOMAN;
return Math.round(Number(eur) * r * markupForEuro(Number(eur)));
}
export function sellPriceFromItem(item, rate = getEurToTomanRate()) {
const eur = euroBase(item);
if (eur != null) return eurToTomanSell(eur, rate);
const n = Number(item?.price);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
export function sellCompareFromItem(item, rate = getEurToTomanRate()) {
const eur = euroBase(item);
const compare = Number(item?.compareAtPrice);
if (
eur != null &&
Number.isFinite(compare) &&
compare > 0 &&
compare < EUR_DETECT_MAX
) {
return eurToTomanSell(compare, rate);
}
if (Number.isFinite(compare) && compare > 0) return Math.round(compare);
return null;
}
+316
View File
@@ -0,0 +1,316 @@
import {
hydrateCart,
subscribe,
getSnapshot,
formatPrice,
} from "/js/cart.js";
function qs(sel, root = document) {
return root.querySelector(sel);
}
function qsa(sel, root = document) {
return [...root.querySelectorAll(sel)];
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function initHeader() {
hydrateCart();
const header = qs(".site-header");
const mega = qs(".mega-panel");
const backdrop = qs(".mega-backdrop");
const drawer = qs(".mobile-drawer");
const badge = qs("[data-cart-count]");
const cartMenu = qs("[data-cart-menu]");
const cartTrigger = qs("[data-cart-trigger]");
const cartPreview = qs("[data-cart-preview]");
const cartList = qs("[data-cart-preview-list]");
const cartEmpty = qs("[data-cart-preview-empty]");
const cartFoot = qs("[data-cart-preview-foot]");
const cartTotal = qs("[data-cart-preview-total]");
const fineHover =
typeof window.matchMedia === "function" &&
window.matchMedia("(hover: hover) and (pointer: fine)").matches;
function syncBadge(snap) {
if (!badge) return;
badge.textContent = String(snap.itemCount);
badge.classList.toggle("is-visible", snap.hydrated && snap.itemCount > 0);
}
function renderCartPreview(snap) {
if (!cartList || !cartEmpty || !cartFoot || !cartTotal) return;
const empty = !snap.items.length;
cartEmpty.hidden = !empty;
cartFoot.hidden = empty;
cartList.hidden = empty;
cartTotal.textContent = formatPrice(snap.subtotal);
if (empty) {
cartList.innerHTML = "";
return;
}
cartList.innerHTML = snap.items
.slice(0, 8)
.map((item) => {
const href = `/products/${escapeHtml(item.slug)}/`;
const img = item.image
? `<img src="${escapeHtml(item.image)}" alt="" width="52" height="66" loading="lazy" />`
: `<div class="cart-preview-ph" aria-hidden="true"></div>`;
return `<a class="cart-preview-line" href="${href}">
${img}
<div class="cart-preview-meta">
<p class="cart-preview-name">${escapeHtml(item.name)}</p>
<p class="cart-preview-qty">× ${item.quantity.toLocaleString("fa-IR")}</p>
</div>
<p class="cart-preview-price">${formatPrice(item.price * item.quantity)}</p>
</a>`;
})
.join("");
}
function setCartPreviewOpen(open) {
if (!cartMenu || !cartPreview || !cartTrigger) return;
cartMenu.classList.toggle("is-open", open);
cartPreview.classList.toggle("is-open", open);
if (open) cartPreview.removeAttribute("hidden");
else cartPreview.setAttribute("hidden", "");
cartTrigger.setAttribute("aria-expanded", open ? "true" : "false");
}
function closeCartPreview() {
setCartPreviewOpen(false);
}
function openCartPreview() {
setCartPreviewOpen(true);
}
const sideNav = qs(".side-nav");
const sideNavToggle = qs("[data-side-nav-toggle]");
const SIDE_NAV_KEY = "kikomiko-side-nav-collapsed";
function setSideNavCollapsed(collapsed) {
if (!sideNav || !sideNavToggle) return;
sideNav.classList.toggle("is-collapsed", collapsed);
sideNavToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
const label = collapsed ? "نمایش منو" : "پنهان کردن منو";
sideNavToggle.setAttribute("aria-label", label);
sideNavToggle.setAttribute("title", label);
try {
localStorage.setItem(SIDE_NAV_KEY, collapsed ? "1" : "0");
} catch {
/* ignore */
}
if (collapsed) closeMega();
}
function hideDesktopMenu() {
closeMega();
setSideNavCollapsed(true);
}
subscribe((snap) => {
syncBadge(snap);
renderCartPreview(snap);
});
const initial = getSnapshot();
syncBadge(initial);
renderCartPreview(initial);
if (cartMenu && cartTrigger && cartPreview) {
cartTrigger.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const open = cartMenu.classList.contains("is-open");
if (open) closeCartPreview();
else {
closeMega();
openCartPreview();
}
});
if (fineHover) {
let leaveTimer = 0;
cartMenu.addEventListener("mouseenter", () => {
window.clearTimeout(leaveTimer);
openCartPreview();
});
cartMenu.addEventListener("mouseleave", () => {
leaveTimer = window.setTimeout(closeCartPreview, 180);
});
}
document.addEventListener("click", (e) => {
if (!(e.target instanceof Element)) return;
if (!cartMenu.contains(e.target)) closeCartPreview();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeCartPreview();
});
}
function closeMega() {
mega?.classList.remove("is-open");
mega?.removeAttribute("data-active");
backdrop?.classList.remove("is-open");
backdrop?.setAttribute("hidden", "");
header?.classList.remove("is-open");
qsa("[data-mega-key]").forEach((b) => b.classList.remove("is-active"));
qsa("[data-mega-pane]").forEach((pane) => {
pane.hidden = true;
});
// Restore home highlight when on home
const home = qs('.side-nav-link[href="/"]');
if (home && (location.pathname === "/" || location.pathname === "/fa")) {
home.classList.add("is-active");
}
}
function openMega(key) {
if (!mega) return;
closeCartPreview();
// Keep the side panel open while browsing mega categories
if (sideNav?.classList.contains("is-collapsed")) {
setSideNavCollapsed(false);
}
mega.setAttribute("data-active", key);
mega.classList.add("is-open");
header?.classList.add("is-open");
backdrop?.classList.add("is-open");
backdrop?.removeAttribute("hidden");
qsa("[data-mega-pane]").forEach((pane) => {
pane.hidden = pane.getAttribute("data-mega-pane") !== key;
});
qsa(".side-nav-link").forEach((b) => b.classList.remove("is-active"));
qsa("[data-mega-key]").forEach((b) =>
b.classList.toggle("is-active", b.getAttribute("data-mega-key") === key)
);
}
qsa("[data-mega-key]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const key = btn.getAttribute("data-mega-key");
if (!key) return;
const open = mega?.getAttribute("data-active") === key;
if (open) {
closeMega();
return;
}
openMega(key);
});
});
// Close mega immediately when any link inside it is chosen
// (categories, view-all, featured products, collection title).
mega?.addEventListener(
"click",
(e) => {
const link = e.target instanceof Element ? e.target.closest("a[href]") : null;
if (!link || !mega.contains(link)) return;
hideDesktopMenu();
},
true
);
// Home / offers links in side nav should close mega + collapse panel
qsa(".side-nav-link[href]").forEach((link) => {
link.addEventListener("click", () => hideDesktopMenu());
});
// Highlight active offers item
if (location.pathname.startsWith("/offers")) {
qsa(".side-nav-link").forEach((el) => el.classList.remove("is-active"));
qs(
'.side-nav-link[href="/offers/"], .side-nav-link[href="/offers"]'
)?.classList.add("is-active");
}
// Do not use mouseleave — it fights with clicks on mega links.
backdrop?.addEventListener("click", closeMega);
qs("[data-mega-close]")?.addEventListener("click", closeMega);
function closeDrawer(e) {
e?.preventDefault?.();
e?.stopPropagation?.();
drawer?.classList.remove("is-open");
drawer?.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
qsa("[data-mobile-sub]").forEach((p) => p.classList.remove("is-open"));
qsa(".mobile-cat").forEach((c) => c.classList.remove("is-expanded"));
}
function openDrawer(e) {
e?.preventDefault?.();
drawer?.classList.add("is-open");
drawer?.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
closeMega();
closeCartPreview();
}
qs("[data-menu-open]")?.addEventListener("click", openDrawer);
qsa("[data-menu-close]").forEach((el) => {
el.addEventListener("click", closeDrawer);
});
qsa("[data-mobile-expand]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-mobile-expand");
const panel = qs(`[data-mobile-sub="${id}"]`);
const cat = btn.closest(".mobile-cat");
const open = panel?.classList.contains("is-open");
qsa("[data-mobile-sub]").forEach((p) => p.classList.remove("is-open"));
qsa(".mobile-cat").forEach((c) => c.classList.remove("is-expanded"));
if (!open) {
panel?.classList.add("is-open");
cat?.classList.add("is-expanded");
}
});
});
// Highlight current route in the mobile drawer
const path = window.location.pathname.replace(/\/+$/, "") || "/";
qsa(".mobile-nav a[href]").forEach((a) => {
const href = (a.getAttribute("href") || "").replace(/\/+$/, "") || "/";
if (href === path || (href !== "/" && path.startsWith(href))) {
a.setAttribute("aria-current", "page");
a.classList.add("is-active");
}
});
try {
if (localStorage.getItem(SIDE_NAV_KEY) === "1") {
setSideNavCollapsed(true);
}
} catch {
/* ignore */
}
sideNavToggle?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setSideNavCollapsed(!sideNav?.classList.contains("is-collapsed"));
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeMega();
closeDrawer();
closeCartPreview();
}
});
}
+580
View File
@@ -0,0 +1,580 @@
/**
* Client-side invoice PDF: نوع محصول · عدد · قیمت · عکس
* Site palette + KikoMiko wordmark linked to kikomiko.ir
*/
const PALETTE = {
ink: "#22191a",
background: "#fffcfb",
soft: "#f8f1ef",
blush: "#f4e4e0",
berry: "#9a3d4f",
muted: "#8a7874",
gold: "#c4a07a",
cardBorder: "#eaddd8",
white: "#ffffff",
};
const SITE_URL = "https://kikomiko.ir/";
const SITE_HOST = "kikomiko.ir";
function formatFaInt(n) {
return new Intl.NumberFormat("fa-IR", { maximumFractionDigits: 0 }).format(n);
}
function loadImage(src) {
return new Promise((resolve) => {
if (!src) {
resolve(null);
return;
}
const img = new Image();
img.decoding = "async";
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
function wrapText(ctx, text, maxWidth) {
const words = String(text || "").split(/\s+/).filter(Boolean);
if (!words.length) return [""];
const lines = [];
let line = words[0];
for (let i = 1; i < words.length; i++) {
const test = `${line} ${words[i]}`;
if (ctx.measureText(test).width <= maxWidth) line = test;
else {
lines.push(line);
line = words[i];
}
}
lines.push(line);
return lines;
}
async function ensureInvoiceFonts() {
try {
if (document.fonts?.load) {
await Promise.all([
document.fonts.load('700 28px "Yekan Bakh"'),
document.fonts.load('300 28px "Yekan Bakh"'),
document.fonts.load('400 13px "Yekan Bakh"'),
]);
}
} catch {
/* fallback fonts below */
}
}
/**
* Draw site wordmark (Kiko bold berry + Miko light ink) + kikomiko.ir under it.
* @returns {{ x: number; y: number; w: number; h: number }} hit area for PDF link
*/
function drawBrandLogo(ctx, fontFamily, rightX, topY) {
const mark = "Kiko";
const tail = "Miko";
ctx.save();
ctx.direction = "ltr";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.font = `700 26px ${fontFamily}`;
const markW = ctx.measureText(mark).width;
ctx.font = `300 26px ${fontFamily}`;
const tailW = ctx.measureText(tail).width;
const logoW = markW + tailW;
const logoX = rightX - logoW;
ctx.font = `700 26px ${fontFamily}`;
ctx.fillStyle = PALETTE.berry;
ctx.fillText(mark, logoX, topY);
ctx.font = `300 26px ${fontFamily}`;
ctx.fillStyle = PALETTE.ink;
ctx.fillText(tail, logoX + markW, topY);
ctx.font = `500 11px ${fontFamily}`;
ctx.fillStyle = PALETTE.berry;
const urlW = ctx.measureText(SITE_HOST).width;
ctx.fillText(SITE_HOST, rightX - urlW, topY + 32);
ctx.strokeStyle = PALETTE.gold;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(rightX - urlW, topY + 45);
ctx.lineTo(rightX, topY + 45);
ctx.stroke();
ctx.restore();
return {
x: Math.min(logoX, rightX - urlW),
y: topY,
w: Math.max(logoW, urlW),
h: 48,
};
}
/**
* Embed JPEG page images into a PDF (A4). Optional link rects in JPEG pixel space (top-left origin).
* @param {Array<{ bytes: Uint8Array; w: number; h: number; links?: Array<{ x: number; y: number; w: number; h: number; url: string }> }>} pages
*/
function jpegsToPdfBlob(pages) {
const pageW = 595.28;
const pageH = 841.89;
const margin = 18;
const encoder = new TextEncoder();
const objects = [];
function addObj(body) {
objects.push(body);
return objects.length;
}
function pdfEscape(s) {
return String(s).replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
const fontId = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`);
const pageIds = [];
for (const page of pages) {
const maxW = pageW - margin * 2;
const maxH = pageH - margin * 2;
const fit = Math.min(maxW / page.w, maxH / page.h, 1);
const dw = page.w * fit;
const dh = page.h * fit;
const x = (pageW - dw) / 2;
const y = pageH - margin - dh;
const annotIds = [];
for (const link of page.links || []) {
const sx = dw / page.w;
const sy = dh / page.h;
const left = x + link.x * sx;
const bottom = y + (page.h - link.y - link.h) * sy;
const right = left + link.w * sx;
const top = bottom + link.h * sy;
const annotId = addObj(
`<< /Type /Annot /Subtype /Link /Rect [${left.toFixed(2)} ${bottom.toFixed(2)} ${right.toFixed(2)} ${top.toFixed(2)}] /Border [0 0 0] /A << /S /URI /URI (${pdfEscape(link.url)}) >> >>`
);
annotIds.push(annotId);
}
const imgId = addObj("IMG");
const contentStream = `q\n${dw.toFixed(2)} 0 0 ${dh.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm\n/Im0 Do\nQ\n`;
const contentId = addObj(
`<< /Length ${encoder.encode(contentStream).length} >>\nstream\n${contentStream}\nendstream`
);
const annots =
annotIds.length > 0
? ` /Annots [${annotIds.map((id) => `${id} 0 R`).join(" ")}]`
: "";
const pageId = addObj(
`<< /Type /Page /Parent 0 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentId} 0 R /Resources << /XObject << /Im0 ${imgId} 0 R >> /Font << /F1 ${fontId} 0 R >> >>${annots} >>`
);
pageIds.push({ pageId, imgId, jpeg: page.bytes, imgW: page.w, imgH: page.h });
}
const kids = pageIds.map((p) => `${p.pageId} 0 R`).join(" ");
const pagesId = addObj(`<< /Type /Pages /Kids [${kids}] /Count ${pageIds.length} >>`);
const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
for (const p of pageIds) {
objects[p.pageId - 1] = objects[p.pageId - 1].replace(
"/Parent 0 0 R",
`/Parent ${pagesId} 0 R`
);
}
const imgIdSet = new Map(pageIds.map((p) => [p.imgId, p]));
const parts = [];
const offsets = [0];
let offset = 0;
function pushStr(s) {
const b = typeof s === "string" ? encoder.encode(s) : s;
parts.push(b);
offset += b.length;
}
pushStr("%PDF-1.4\n");
for (let i = 0; i < objects.length; i++) {
offsets.push(offset);
const n = i + 1;
const imgPage = imgIdSet.get(n);
if (imgPage) {
const jpeg = imgPage.jpeg;
pushStr(`${n} 0 obj\n`);
pushStr(
`<< /Type /XObject /Subtype /Image /Width ${imgPage.imgW} /Height ${imgPage.imgH} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.length} >>\nstream\n`
);
parts.push(jpeg);
offset += jpeg.length;
pushStr("\nendstream\nendobj\n");
} else {
pushStr(`${n} 0 obj\n${objects[i]}\nendobj\n`);
}
}
const xrefStart = offset;
pushStr(`xref\n0 ${objects.length + 1}\n`);
pushStr("0000000000 65535 f \n");
for (let i = 1; i <= objects.length; i++) {
pushStr(`${String(offsets[i]).padStart(10, "0")} 00000 n \n`);
}
pushStr(
`trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`
);
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return new Blob([out], { type: "application/pdf" });
}
function canvasToJpegBytes(canvas, quality = 0.92) {
return new Promise((resolve, reject) => {
canvas.toBlob(
async (blob) => {
if (!blob) {
reject(new Error("JPEG encode failed"));
return;
}
resolve(new Uint8Array(await blob.arrayBuffer()));
},
"image/jpeg",
quality
);
});
}
/**
* @param {{
* orderId: string;
* dateLabel: string;
* customerName: string;
* customerPhone?: string;
* address?: string;
* city?: string;
* province?: string;
* items: Array<{ name: string; category?: string|null; quantity: number; price: number; image?: string|null }>;
* totalLabel: string;
* formatPrice: (n: number) => string;
* eurToTomanRate?: number;
* }} data
* @returns {Promise<File>}
*/
export async function generateInvoicePdf(data) {
await ensureInvoiceFonts();
const images = await Promise.all(data.items.map((i) => loadImage(i.image)));
const W = 794;
const pad = 36;
const thumbW = 72;
const thumbH = 96;
const colQty = 64;
const colPrice = 130;
const colImg = 92;
const colType = W - pad * 2 - colQty - colPrice - colImg;
const rowPad = 10;
const font = '"Yekan Bakh",Tahoma,"Segoe UI",Arial,sans-serif';
const measure = document.createElement("canvas").getContext("2d");
if (!measure) throw new Error("Canvas unavailable");
measure.font = `12px ${font}`;
const rowHeights = data.items.map((item) => {
const type = item.category || "—";
const nameLines = wrapText(measure, item.name || "", colType - 16);
const typeLines = wrapText(measure, type, colType - 16);
const textH = (typeLines.length + nameLines.length) * 16 + 8;
return Math.max(thumbH + rowPad * 2, textH + rowPad * 2);
});
const headerBandH = 64;
const infoBlockEstimate = 70;
const tableHeadH = 36;
const footerH = 88;
const tableH = rowHeights.reduce((a, b) => a + b, 0);
const H = pad + headerBandH + 16 + infoBlockEstimate + tableHeadH + tableH + footerH;
const scale = 2;
const canvas = document.createElement("canvas");
canvas.width = W * scale;
canvas.height = H * scale;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas unavailable");
ctx.scale(scale, scale);
ctx.fillStyle = PALETTE.background;
ctx.fillRect(0, 0, W, H);
ctx.textBaseline = "top";
ctx.direction = "rtl";
let y = 0;
ctx.fillStyle = PALETTE.soft;
ctx.fillRect(0, 0, W, headerBandH);
ctx.fillStyle = PALETTE.berry;
ctx.fillRect(0, headerBandH - 3, W, 3);
y = 14;
const logoHit = drawBrandLogo(ctx, font, W - pad, y);
ctx.textAlign = "left";
ctx.direction = "ltr";
ctx.fillStyle = PALETTE.ink;
ctx.font = `700 12px ${font}`;
ctx.fillText(data.orderId, pad, y + 8);
ctx.font = `400 11px ${font}`;
ctx.fillStyle = PALETTE.muted;
ctx.fillText(data.dateLabel, pad, y + 28);
ctx.direction = "rtl";
y = headerBandH + 18;
ctx.fillStyle = PALETTE.ink;
ctx.font = `400 13px ${font}`;
ctx.textAlign = "right";
const info = [
`نام مشتری: ${data.customerName}`,
data.customerPhone ? `تلفن: ${data.customerPhone}` : "",
data.address
? `آدرس: ${data.address}${data.city ? ` — ${data.city}` : ""}${data.province ? `، ${data.province}` : ""}`
: "",
].filter(Boolean);
for (const line of info) {
ctx.fillText(line, W - pad, y);
y += 20;
}
y += 10;
const cols = [
{ key: "type", w: colType, label: "نوع محصول" },
{ key: "qty", w: colQty, label: "عدد" },
{ key: "price", w: colPrice, label: "قیمت" },
{ key: "img", w: colImg, label: "عکس" },
];
function colXs() {
let right = W - pad;
return cols.map((c) => {
const x0 = right - c.w;
right = x0;
return { ...c, x: x0 };
});
}
const headerCols = colXs();
ctx.fillStyle = PALETTE.blush;
ctx.fillRect(pad, y, W - pad * 2, tableHeadH);
ctx.strokeStyle = PALETTE.cardBorder;
ctx.lineWidth = 1;
ctx.strokeRect(pad, y, W - pad * 2, tableHeadH);
ctx.fillStyle = PALETTE.berry;
ctx.font = `700 12px ${font}`;
for (const c of headerCols) {
ctx.strokeRect(c.x, y, c.w, tableHeadH);
ctx.textAlign = "center";
ctx.fillText(c.label, c.x + c.w / 2, y + 11);
}
y += tableHeadH;
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
const rh = rowHeights[i];
const rowCols = colXs();
ctx.fillStyle = i % 2 === 0 ? PALETTE.white : PALETTE.soft;
ctx.fillRect(pad, y, W - pad * 2, rh);
ctx.strokeStyle = PALETTE.cardBorder;
ctx.strokeRect(pad, y, W - pad * 2, rh);
for (const c of rowCols) ctx.strokeRect(c.x, y, c.w, rh);
const typeCol = rowCols.find((c) => c.key === "type");
const qtyCol = rowCols.find((c) => c.key === "qty");
const priceCol = rowCols.find((c) => c.key === "price");
const imgCol = rowCols.find((c) => c.key === "img");
ctx.fillStyle = PALETTE.ink;
ctx.font = `700 12px ${font}`;
ctx.textAlign = "right";
const type = item.category || "—";
let ty = y + rowPad;
for (const line of wrapText(ctx, type, colType - 16)) {
ctx.fillText(line, typeCol.x + typeCol.w - 8, ty);
ty += 16;
}
ctx.font = `400 11px ${font}`;
ctx.fillStyle = PALETTE.muted;
for (const line of wrapText(ctx, item.name || "", colType - 16)) {
ctx.fillText(line, typeCol.x + typeCol.w - 8, ty);
ty += 15;
}
ctx.fillStyle = PALETTE.ink;
ctx.font = `500 13px ${font}`;
ctx.textAlign = "center";
ctx.fillText(formatFaInt(item.quantity), qtyCol.x + qtyCol.w / 2, y + rh / 2 - 7);
ctx.fillText(
data.formatPrice(item.price * item.quantity),
priceCol.x + priceCol.w / 2,
y + rh / 2 - 7
);
const img = images[i];
const ix = imgCol.x + (imgCol.w - thumbW) / 2;
const iy = y + (rh - thumbH) / 2;
if (img) {
ctx.drawImage(img, ix, iy, thumbW, thumbH);
ctx.strokeStyle = PALETTE.cardBorder;
ctx.strokeRect(ix, iy, thumbW, thumbH);
} else {
ctx.fillStyle = PALETTE.blush;
ctx.fillRect(ix, iy, thumbW, thumbH);
}
y += rh;
}
y += 14;
ctx.strokeStyle = PALETTE.gold;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(W - pad, y);
ctx.stroke();
y += 14;
ctx.fillStyle = PALETTE.berry;
ctx.font = `700 15px ${font}`;
ctx.textAlign = "right";
ctx.fillText("جمع کل", W - pad, y);
ctx.textAlign = "left";
ctx.direction = "ltr";
ctx.fillText(data.totalLabel, pad, y);
ctx.direction = "rtl";
const eurRate =
typeof data.eurToTomanRate === "number" &&
Number.isFinite(data.eurToTomanRate) &&
data.eurToTomanRate > 0
? data.eurToTomanRate
: 0;
if (eurRate > 0) {
y += 26;
ctx.fillStyle = PALETTE.muted;
ctx.font = `400 12px ${font}`;
ctx.textAlign = "right";
const rateLabel = `نرخ یورو: ${data.formatPrice(eurRate)}`;
ctx.fillText(rateLabel, W - pad, y);
}
const jpegFull = await canvasToJpegBytes(canvas, 0.92);
const pageCssH = Math.floor((W * 841.89) / 595.28);
const pages = [];
const logoLinkJpeg = {
x: logoHit.x * scale,
y: logoHit.y * scale,
w: logoHit.w * scale,
h: logoHit.h * scale,
url: SITE_URL,
};
if (H <= pageCssH + 40) {
pages.push({
bytes: jpegFull,
w: canvas.width,
h: canvas.height,
links: [logoLinkJpeg],
});
} else {
let sy = 0;
let pageIndex = 0;
while (sy < H) {
const sliceH = Math.min(pageCssH, H - sy);
const slice = document.createElement("canvas");
slice.width = canvas.width;
slice.height = Math.ceil(sliceH * scale);
const sctx = slice.getContext("2d");
if (!sctx) break;
sctx.drawImage(
canvas,
0,
Math.floor(sy * scale),
canvas.width,
slice.height,
0,
0,
slice.width,
slice.height
);
const pageLinks =
pageIndex === 0 && logoHit.y + logoHit.h <= sliceH ? [logoLinkJpeg] : [];
pages.push({
bytes: await canvasToJpegBytes(slice, 0.92),
w: slice.width,
h: slice.height,
links: pageLinks,
});
sy += sliceH;
pageIndex += 1;
}
}
const blob = jpegsToPdfBlob(pages);
return new File([blob], `kikomiko-invoice-${data.orderId}.pdf`, {
type: "application/pdf",
lastModified: Date.now(),
});
}
export function downloadFile(file) {
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function whatsappChatUrl(waNumber, text) {
const digits = String(waNumber || "").replace(/\D/g, "");
return `https://wa.me/${digits}?text=${encodeURIComponent(text || "")}`;
}
/** Upload PDF to Worker; returns public URL for WhatsApp (no client download). */
export async function uploadInvoicePdf(file) {
const res = await fetch("/api/invoices", {
method: "POST",
headers: { "content-type": "application/pdf" },
body: file,
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.ok || !json.url) {
throw new Error(json.error || "آپلود فاکتور ناموفق بود");
}
return String(json.url);
}
/**
* Host PDF, open WhatsApp to store with invoice link (no client download).
*/
export async function openWhatsAppWithInvoice(file, waText, waNumber) {
const pdfUrl = await uploadInvoicePdf(file);
const note = [waText || "", "", "📄 Invoice PDF:", pdfUrl]
.filter((line, i, arr) => !(line === "" && arr[i - 1] === ""))
.join("\n")
.trim();
const url = whatsappChatUrl(waNumber, note);
window.location.href = url;
return { pdfUrl, waUrl: url };
}
+296
View File
@@ -0,0 +1,296 @@
/**
* Iran provinces + major cities for checkout selects.
* Cities are choosable after province (no free-text required).
*/
export const IRAN_PROVINCES = [
{
name: "آذربایجان شرقی",
cities: [
"تبریز",
"مراغه",
"مرند",
"میانه",
"اهر",
"بناب",
"سراب",
"آذرشهر",
"شبستر",
"هشترود",
],
},
{
name: "آذربایجان غربی",
cities: [
"ارومیه",
"خوی",
"مهاباد",
"میاندوآب",
"بوکان",
"سلماس",
"پیرانشهر",
"نقده",
"سردشت",
"تکاب",
],
},
{
name: "اردبیل",
cities: ["اردبیل", "پارس‌آباد", "مشگین‌شهر", "خلخال", "گرمی", "نمین", "نیر"],
},
{
name: "اصفهان",
cities: [
"اصفهان",
"کاشان",
"خمینی‌شهر",
"نجف‌آباد",
"شاهین‌شهر",
"فلاورجان",
"زرین‌شهر",
"گلپایگان",
"نطنز",
"اردستان",
"نایین",
"شهرضا",
],
},
{
name: "البرز",
cities: ["کرج", "فردیس", "نظرآباد", "ساوجبلاغ", "اشتهارد", "طالقان", "هشتگرد"],
},
{
name: "ایلام",
cities: ["ایلام", "دهلران", "آبدانان", "ایوان", "مهران", "دره‌شهر"],
},
{
name: "بوشهر",
cities: ["بوشهر", "برازجان", "گناوه", "کنگان", "عسلویه", "جم", "دیر", "دیلم"],
},
{
name: "تهران",
cities: [
"تهران",
"اسلامشهر",
"شهریار",
"قدس",
"ملارد",
"پاکدشت",
"ری",
"ورامین",
"پردیس",
"دماوند",
"فیروزکوه",
"بومهن",
"رباط‌کریم",
"اندیشه",
],
},
{
name: "چهارمحال و بختیاری",
cities: ["شهرکرد", "بروجن", "فارسان", "لردگان", "سامان", "کیان"],
},
{
name: "خراسان جنوبی",
cities: ["بیرجند", "قائن", "طبس", "فردوس", "نهبندان", "سربیشه"],
},
{
name: "خراسان رضوی",
cities: [
"مشهد",
"نیشابور",
"سبزوار",
"تربت‌حیدریه",
"کاشمر",
"قوچان",
"تربت‌جام",
"چناران",
"گناباد",
"تایباد",
"سرخس",
],
},
{
name: "خراسان شمالی",
cities: ["بجنورد", "شیروان", "اسفراین", "جاجرم", "فاروج", "گرمه"],
},
{
name: "خوزستان",
cities: [
"اهواز",
"آبادان",
"دزفول",
"ماهشهر",
"اندیمشک",
"خرمشهر",
"بهبهان",
"ایذه",
"شوشتر",
"مسجدسلیمان",
"شادگان",
"رامهرمز",
],
},
{
name: "زنجان",
cities: ["زنجان", "ابهر", "خرمدره", "قیدار", "آب‌بر", "سلطانیه"],
},
{
name: "سمنان",
cities: ["سمنان", "شاهرود", "دامغان", "گرمسار", "مهدی‌شهر", "ایوانکی"],
},
{
name: "سیستان و بلوچستان",
cities: [
"زاهدان",
"چابهار",
"ایرانشهر",
"زابل",
"کنارک",
"سراوان",
"خاش",
"نیک‌شهر",
],
},
{
name: "فارس",
cities: [
"شیراز",
"مرودشت",
"جهرم",
"فسا",
"کازرون",
"لار",
"داراب",
"آباده",
"نی‌ریز",
"اقلید",
"لامرد",
],
},
{
name: "قزوین",
cities: ["قزوین", "تاکستان", "الوند", "آبیک", "بوئین‌زهرا", "محمدیه"],
},
{
name: "قم",
cities: ["قم", "جعفریه", "کهک", "سلفچگان"],
},
{
name: "کردستان",
cities: ["سنندج", "سقز", "مریوان", "بانه", "قروه", "بیجار", "کامیاران"],
},
{
name: "کرمان",
cities: [
"کرمان",
"رفسنجان",
"جیرفت",
"سیرجان",
"بم",
"زرند",
"کهنوج",
"شهربابک",
"راور",
],
},
{
name: "کرمانشاه",
cities: [
"کرمانشاه",
"اسلام‌آباد غرب",
"جوانرود",
"کنگاور",
"سرپل‌ذهاب",
"سنقر",
"هرسین",
"صحنه",
],
},
{
name: "کهگیلویه و بویراحمد",
cities: ["یاسوج", "دوگنبدان", "دهدشت", "لیکک", "سی‌سخت"],
},
{
name: "گلستان",
cities: [
"گرگان",
"گنبدکاووس",
"علی‌آباد کتول",
"بندرترکمن",
"آق‌قلا",
"کردکوی",
"مینودشت",
"کلاله",
],
},
{
name: "گیلان",
cities: [
"رشت",
"انزلی",
"لاهیجان",
"لنگرود",
"آستارا",
"تالش",
"صومعه‌سرا",
"رودسر",
"فومن",
"آستانه اشرفیه",
],
},
{
name: "لرستان",
cities: [
"خرم‌آباد",
"بروجرد",
"دورود",
"الیگودرز",
"کوهدشت",
"نورآباد",
"ازنا",
"پلدختر",
],
},
{
name: "مازندران",
cities: [
"ساری",
"بابل",
"آمل",
"قائم‌شهر",
"بهشهر",
"چالوس",
"تنکابن",
"نوشهر",
"بابلسر",
"محمودآباد",
"رامسر",
"نکا",
],
},
{
name: "مرکزی",
cities: ["اراک", "ساوه", "خمین", "محلات", "دلیجان", "تفرش", "آشتیان"],
},
{
name: "هرمزگان",
cities: [
"بندرعباس",
"میناب",
"قشم",
"کیش",
"بندرلنگه",
"حاجی‌آباد",
"پارسیان",
"جاسک",
],
},
{
name: "همدان",
cities: ["همدان", "ملایر", "نهاوند", "اسدآباد", "تویسرکان", "کبودرآهنگ", "رزن"],
},
{
name: "یزد",
cities: ["یزد", "میبد", "اردکان", "بافق", "مهریز", "ابرکوه", "تفت"],
},
];
+96
View File
@@ -0,0 +1,96 @@
/** KIKO "2+1 LOVE YOUR EYES" — keep in sync with src/lib/promos/love-your-eyes.ts */
export const LOVE_YOUR_EYES_LABEL = "2+1 LOVE YOUR EYES";
export const LOVE_YOUR_EYES_FAMILIES = [
"30-days-extension-night-treatment-booster-mas",
"building-base-mascara-volumizing-caring-effec",
"unlimited-precision-automatic-eyeliner-khol",
"luxurious-lashes-maxi-volume-brush-mascara",
"24h-long-lasting-panoramic-volume-mascara",
"30-days-extension-daily-treatment-mascara",
"kind-by-kiko-soft-touch-eyeshadow-palette",
"glamour-multi-finish-eyeshadow-palette",
"kind-by-kiko-high-definition-eyeliner",
"new-pure-clean-micellar-biphase-water",
"new-pure-clean-micellar-cleansing-oil",
"intense-colour-long-lasting-eyeliner",
"kind-by-kiko-defining-eyebrow-pencil",
"luxurious-lashes-waterproof-mascara",
"kind-by-kiko-lasting-eyeliner-khol",
"unmeasurable-length-fibers-mascara",
"standout-volume-buildable-mascara",
"bright-quartet-eyeshadow-palette",
"super-colour-waterproof-eyeliner",
"ultimate-pen-waterproof-eyeliner",
"extra-sculpt-waterproof-mascara",
"eyebrow-sculpt-automatic-pencil",
"new-maxi-mod-waterproof-mascara",
"twistable-volume-length-mascara",
"definition-waterproof-eyeliner",
"eyes-59-slanted-blending-brush",
"micro-precision-eyebrow-pencil",
"extra-volume-wash-off-mascara",
"false-eyelashes-volume-effect",
"kind-by-kiko-double-eye-brush",
"long-lasting-liquid-eyeshadow",
"new-pure-clean-cleansing-balm",
"new-pure-clean-micellar-water",
"eyebrow-designer-gel-mascara",
"long-lasting-eyeshadow-stick",
"longeyes-plus-active-mascara",
"maxi-mod-length-curl-mascara",
"new-pure-clean-eyeslips-mini",
"extra-sculpt-volume-mascara",
"kind-by-kiko-volume-mascara",
"new-pure-clean-micellar-gel",
"soft-nude-eyeshadow-palette",
"eyebrow-wax-fixing-pencil",
"new-pure-clean-wipes-mini",
"arch-appeal-brow-mascara",
"eyebrow-nourishing-serum",
"eyes-60-pencil-eye-brush",
"glitter-shower-eyeshadow",
"master-eyeshadow-palette",
"precision-eyebrow-pencil",
"new-pure-clean-eyeslips",
"smart-colour-eye-pencil",
"daring-look-eye-marker",
"eyebrow-expert-palette",
"high-pigment-eyeshadow",
"smart-colour-eyeshadow",
"ultimate-pen-eyeliner",
"bright-duo-eyeshadow",
"false-eyelashes-glue",
"lasting-gel-eyeliner",
"new-maxi-mod-mascara",
"new-pure-clean-scrub",
"new-pure-clean-toner",
"new-pure-clean-wipes",
"smart-colour-mascara",
"definition-eyeliner",
"lasting-eyebrow-gel",
"new-pure-clean-foam",
"party-saver-mascara",
"micro-tip-eyeliner",
"precision-eyeliner",
"everlasting-kajal",
"volume-attraction",
"volumeyes-mascara",
"glitter-eyeliner",
"maxi-mod-mascara",
"neutral-eye-base",
"water-eyeshadow",
"winged-eyeliner",
"eyebrow-marker",
"colour-kajal"
];
/** Promo ended — always false so product badges / PLP filters stay off. */
export function hasLoveYourEyesOffer(_slug) {
return false;
}
/** Promo ended — no 2+1 eyes pill on cards or PDP. */
export function loveYourEyesPillHtml(_slug) {
return "";
}
+36
View File
@@ -0,0 +1,36 @@
import { initHeader } from "/js/header.js?v=logoMenu1";
import { initReveals } from "/js/reveal.js";
import { initAtelierHero } from "/js/atelier-hero.js?v=scrubStack7";
import { initPromoSlider } from "/js/promo-slider.js?v=promoLyeMob1";
import { initBuyButtons, initShareButtons, initShadePicker, initCardShadePickers, initSortForms, initStaticCatalogHydration } from "/js/buy.js?v=pdpFx1";
import { initProductPage } from "/js/product-page.js?v=pdpFx1";
import { initCartPage } from "/js/cart-page.js?v=cartpage1";
import { initCheckout } from "/js/checkout.js?v=eurInv1";
import { initProductSliders } from "/js/product-slider.js?v=sliderDots1";
import { initCollabBanners } from "/js/collab-banners.js?v=collabFrame1";
import { initFooter } from "/js/footer.js?v=footerEur1";
import { initProductFilters } from "/js/filters.js?v=catRoot1";
import { initSearchSuggest } from "/js/search-suggest.js?v=persianCopyRtl2";
import { ensureEurRate } from "/js/fx.js?v=fx1";
initHeader();
initReveals();
initAtelierHero();
initPromoSlider();
initBuyButtons();
initShareButtons();
initShadePicker();
initCardShadePickers();
initSortForms();
initProductFilters();
initSearchSuggest();
initCartPage();
initCheckout();
initProductSliders();
initCollabBanners();
initFooter();
void (async () => {
await ensureEurRate();
await initStaticCatalogHydration();
await initProductPage();
})();
+58
View File
@@ -0,0 +1,58 @@
// Product-owned text is escaped; imported HTML is never executed.
const esc = (value) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
/** Support note appended after every product description (links are trusted site URLs). */
function descriptionHelpHtml() {
return `<p class="product-description-help" lang="fa" dir="rtl">در صورت نیاز به اطلاعات بیشتر راجع به این محصول می‌توانید به <a href="/contact/">پشتیبانی</a> کیکومیکو پیام دهید یا به وبسایت اصلی <a href="https://www.kikocosmetics.com/" target="_blank" rel="noopener noreferrer">کیکو</a> مراجعه فرمایید.</p>`;
}
function descriptionHtml(description) {
if (!description) return "";
const [intro, ...rest] = String(description).split(/\n\s*\n/);
return `<p class="text-muted product-description" lang="fa"><span data-product-description-intro>${esc(intro)}</span>${
rest.length ? `\n\n${esc(rest.join("\n\n"))}` : ""
}</p>${descriptionHelpHtml()}`;
}
export function detailRows() {
// Detail / facts tables are intentionally unused on the storefront.
return [];
}
export function renderProductContent(container, product, variant = {}) {
const description =
variant.description ||
variant.descriptionIntro ||
product.description ||
product.descriptionIntro ||
"";
if (container) {
const descBlock = description
? `<section class="pdp-section" aria-labelledby="product-desc-title">
<h2 id="product-desc-title">معرفی و توضیحات محصول</h2>
${descriptionHtml(description)}
</section>`
: `<section class="pdp-section" aria-labelledby="product-desc-title">
<h2 id="product-desc-title">معرفی و توضیحات محصول</h2>
${descriptionHelpHtml()}
</section>`;
container.innerHTML = descBlock;
}
document.title =
variant.seoTitle ||
product.seoTitle ||
`${variant.name || product.name} | کیکومیکو`;
const meta = document.querySelector('meta[name="description"]');
if (meta) {
meta.content =
variant.seoDescription || product.seoDescription || description;
}
}
+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));
});
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Horizontal product rails with under-rail dots + 3s autoplay ([data-product-slider]).
* RTL-safe scrollLeft (Chrome negative vs Firefox positive).
*/
export function initProductSliders() {
document.querySelectorAll("[data-product-slider]").forEach((root) => {
const rail = root.querySelector("[data-slider-rail]");
const dotsHost = root.querySelector("[data-slider-dots]");
if (!(rail instanceof HTMLElement)) return;
const rtl =
getComputedStyle(rail).direction === "rtl" ||
document.documentElement.dir === "rtl";
const AUTO_MS = 3000;
// Chrome/Safari RTL: scrollLeft goes negative; Firefox RTL: positive.
let rtlNegative = false;
if (rtl) {
const prevLeft = rail.scrollLeft;
rail.scrollLeft = -1;
rtlNegative = rail.scrollLeft < 0;
rail.scrollLeft = prevLeft;
}
const step = () => {
const card = rail.querySelector(".product-card");
const w = card instanceof HTMLElement ? card.getBoundingClientRect().width : 200;
const styles = getComputedStyle(rail);
const gap = Number.parseFloat(styles.columnGap || styles.gap || "1") || 1;
return Math.max(w + gap, 120);
};
function maxScroll() {
return Math.max(0, rail.scrollWidth - rail.clientWidth);
}
function canScroll() {
return maxScroll() > 4;
}
/** Absolute distance from the logical start (0…max), independent of engine sign. */
function scrollPos() {
return Math.abs(rail.scrollLeft);
}
function scrollToPos(pos, smooth) {
const max = maxScroll();
const target = Math.min(max, Math.max(0, pos));
let left = target;
if (rtl) left = rtlNegative ? -target : target;
rail.scrollTo({ left, behavior: smooth ? "smooth" : "auto" });
}
function pageCount() {
const max = maxScroll();
if (max <= 4) return 1;
return Math.floor(max / step()) + 1;
}
function currentPage() {
const pages = pageCount();
if (pages <= 1) return 0;
const s = step();
return Math.min(pages - 1, Math.round(scrollPos() / s));
}
function goToPage(index, smooth = true) {
const pages = pageCount();
const i = Math.max(0, Math.min(pages - 1, index));
const max = maxScroll();
if (i >= pages - 1) scrollToPos(max, smooth);
else scrollToPos(i * step(), smooth);
}
function goNext() {
const max = maxScroll();
if (max <= 4) return;
const pages = pageCount();
const next = currentPage() + 1;
if (next >= pages) goToPage(0, true);
else goToPage(next, true);
}
function renderDots() {
if (!(dotsHost instanceof HTMLElement)) return;
const pages = pageCount();
if (pages <= 1) {
dotsHost.hidden = true;
dotsHost.replaceChildren();
return;
}
dotsHost.hidden = false;
const active = currentPage();
if (dotsHost.childElementCount !== pages) {
const frag = document.createDocumentFragment();
for (let i = 0; i < pages; i++) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "slider-dot";
btn.setAttribute("role", "tab");
btn.setAttribute("aria-label", `صفحه ${i + 1}`);
btn.addEventListener("click", (e) => {
e.preventDefault();
goToPage(i, true);
startAuto();
});
frag.appendChild(btn);
}
dotsHost.replaceChildren(frag);
}
Array.from(dotsHost.children).forEach((el, i) => {
if (!(el instanceof HTMLElement)) return;
const on = i === active;
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");
});
}
let timer = 0;
function stopAuto() {
window.clearInterval(timer);
timer = 0;
}
function startAuto() {
stopAuto();
if (!canScroll()) return;
timer = window.setInterval(goNext, AUTO_MS);
}
root.addEventListener("mouseenter", stopAuto);
root.addEventListener("mouseleave", startAuto);
root.addEventListener(
"focusin",
(e) => {
if (e.target instanceof Element && e.target.closest(".slider-dot")) {
/* keep running */
} else {
stopAuto();
}
},
true
);
root.addEventListener("focusout", () => {
window.setTimeout(() => {
if (!root.contains(document.activeElement)) startAuto();
}, 0);
});
rail.addEventListener(
"pointerdown",
() => {
stopAuto();
},
{ passive: true }
);
rail.addEventListener(
"pointerup",
() => {
startAuto();
},
{ passive: true }
);
rail.addEventListener(
"scroll",
() => {
renderDots();
},
{ passive: true }
);
window.addEventListener(
"resize",
() => {
renderDots();
startAuto();
},
{ passive: true }
);
// Layout may not be final on first paint (images / fonts).
const kick = () => {
renderDots();
startAuto();
};
kick();
window.requestAnimationFrame(kick);
window.setTimeout(kick, 400);
window.addEventListener("load", kick, { once: true });
});
}
+156
View File
@@ -0,0 +1,156 @@
/**
* KIKO-style homepage promo carousel — progress bars, pause, video sync.
*/
export function initPromoSlider() {
const root = document.querySelector("[data-promo-slider]");
if (!(root instanceof HTMLElement)) return;
const allSlides = [...root.querySelectorAll("[data-phs-slide]")];
const allBars = [...root.querySelectorAll("[data-phs-goto]")];
const allFills = [...root.querySelectorAll("[data-phs-fill]")];
const isShown = (el) => el instanceof HTMLElement && getComputedStyle(el).display !== "none";
const visibleIdx = allSlides
.map((slide, i) => (isShown(slide) ? i : -1))
.filter((i) => i >= 0);
const slides = visibleIdx.map((i) => allSlides[i]);
const bars = visibleIdx.map((i) => allBars[i]);
const fills = visibleIdx.map((i) => allFills[i]);
const toggle = root.querySelector("[data-phs-toggle]");
const iconPause = root.querySelector(".phs-icon-pause");
const iconPlay = root.querySelector(".phs-icon-play");
if (!slides.length) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let index = 0;
let paused = reduce;
let raf = 0;
let startedAt = 0;
let elapsed = 0;
const durationOf = (i) => {
const raw = Number(slides[i]?.getAttribute("data-phs-duration") || 6500);
return Number.isFinite(raw) && raw > 0 ? raw : 6500;
};
const setToggleUi = () => {
if (toggle instanceof HTMLElement) {
toggle.setAttribute("aria-pressed", paused ? "true" : "false");
toggle.setAttribute("aria-label", paused ? "پخش اسلایدر" : "توقف اسلایدر");
}
if (iconPause instanceof HTMLElement) iconPause.hidden = paused;
if (iconPlay instanceof HTMLElement) iconPlay.hidden = !paused;
};
const syncVideo = (activeIndex) => {
slides.forEach((slide, i) => {
const video = slide.querySelector("[data-phs-video]");
if (!(video instanceof HTMLVideoElement)) return;
if (i === activeIndex && !paused) {
video.muted = true;
const p = video.play();
if (p && typeof p.catch === "function") p.catch(() => {});
} else {
try {
video.pause();
} catch {
/* ignore */
}
}
});
};
const setProgress = (i, ratio) => {
fills.forEach((fill, fi) => {
if (!(fill instanceof HTMLElement)) return;
let w = 0;
if (fi < i) w = 1;
else if (fi === i) w = Math.min(1, Math.max(0, ratio));
fill.style.transform = `scaleX(${w})`;
});
};
const show = (next) => {
const i = ((next % slides.length) + slides.length) % slides.length;
index = i;
elapsed = 0;
startedAt = performance.now();
slides.forEach((slide, si) => {
const on = si === i;
slide.classList.toggle("is-active", on);
slide.setAttribute("aria-hidden", on ? "false" : "true");
});
bars.forEach((bar, bi) => bar.classList.toggle("is-active", bi === i));
setProgress(i, 0);
syncVideo(i);
};
const tick = (now) => {
raf = 0;
if (paused) return;
elapsed = now - startedAt;
const dur = durationOf(index);
setProgress(index, elapsed / dur);
if (elapsed >= dur) {
show(index + 1);
}
raf = requestAnimationFrame(tick);
};
const start = () => {
if (paused || reduce) return;
if (!raf) {
startedAt = performance.now() - elapsed;
raf = requestAnimationFrame(tick);
}
};
const stop = () => {
if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
};
bars.forEach((bar, i) => {
bar.addEventListener("click", () => {
stop();
show(i);
start();
});
});
toggle?.addEventListener("click", () => {
paused = !paused;
setToggleUi();
if (paused) {
stop();
elapsed = performance.now() - startedAt;
syncVideo(index);
} else {
start();
syncVideo(index);
}
});
// Pause autoplay when off-screen
const io = new IntersectionObserver(
(entries) => {
const e = entries[0];
if (!e) return;
if (!e.isIntersecting) {
stop();
syncVideo(-1);
} else if (!paused) {
start();
syncVideo(index);
}
},
{ threshold: 0.2 }
);
io.observe(root);
setToggleUi();
show(0);
if (!reduce) start();
}
+22
View File
@@ -0,0 +1,22 @@
export function initReveals() {
const nodes = document.querySelectorAll(".reveal");
if (!nodes.length) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
nodes.forEach((el) => el.classList.add("is-revealed"));
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-revealed");
io.unobserve(entry.target);
}
},
{ rootMargin: "-80px", threshold: 0.01 }
);
nodes.forEach((el) => io.observe(el));
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Vanilla scroll-scrubbed video (no GSAP).
* Pins via CSS sticky; maps scroll progress through spacer to video.currentTime.
*/
export function initScrollScrub() {
const root = document.querySelector("[data-scroll-scrub]");
if (!root) return;
const video = root.querySelector("video");
const spacer = root.querySelector(".scrub-spacer");
if (!(video instanceof HTMLVideoElement) || !spacer) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
video.removeAttribute("autoplay");
video.controls = true;
spacer.style.display = "none";
return;
}
let ready = false;
let seeking = false;
let target = 0;
const onReady = () => {
ready = true;
video.pause();
};
if (video.readyState >= 2) onReady();
else video.addEventListener("loadeddata", onReady, { once: true });
video.addEventListener("seeked", () => {
seeking = false;
if (Math.abs(video.currentTime - target) > 0.04) {
seeking = true;
video.currentTime = target;
}
});
function update() {
if (!ready || !video.duration) return;
const rect = spacer.getBoundingClientRect();
const total = spacer.offsetHeight - window.innerHeight;
const scrolled = -rect.top;
const progress = total > 0 ? Math.min(1, Math.max(0, scrolled / total)) : 0;
target = progress * video.duration;
if (!seeking && Math.abs(video.currentTime - target) > 0.04) {
seeking = true;
video.currentTime = target;
}
}
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
update();
}
+53
View File
@@ -0,0 +1,53 @@
/** Shared client-side catalog search (catalog.dedupe2.json). */
let catalogPromise = null;
export function loadSearchCatalog() {
if (!catalogPromise) {
catalogPromise = fetch("/data/catalog.dedupe2.json")
.then((r) => {
if (!r.ok) throw new Error("catalog fetch failed");
return r.json();
})
.then((data) => data.products || []);
}
return catalogPromise;
}
export 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;
}
export function searchCatalogProducts(products, query, limit) {
const needle = String(query || "").trim().toLowerCase();
if (!needle) return [];
const hits = products.filter((p) => {
const hay = `${p.name} ${p.category || ""} ${p.categoryFa || ""}`.toLowerCase();
return hay.includes(needle);
});
return limit != null ? hits.slice(0, limit) : hits;
}
export function formatSearchPrice(price) {
const amount = Number.isFinite(price) ? Math.round(price) : 0;
return `${new Intl.NumberFormat("fa-IR", { maximumFractionDigits: 0 }).format(amount)} تومان`;
}
export function escapeSearchHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+167
View File
@@ -0,0 +1,167 @@
import {
displayName,
escapeSearchHtml,
formatSearchPrice,
loadSearchCatalog,
searchCatalogProducts,
} from "/js/search-catalog.js?v=persianCopyRtl2";
import { initCardShadePickers } from "/js/buy.js?v=priceSort2";
import { refreshProductFilters } from "/js/filters.js?v=priceSort2";
import { loveYourEyesPillHtml } from "/js/love-your-eyes.js?v=lyeOff";
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('${escapeSearchHtml(v.heroImage)}')`
: "";
const href = `/products/${escapeSearchHtml(v.slug)}/`;
return `<button
type="button"
class="shade-dot${active}"
style="${bg}"
title="${escapeSearchHtml(v.shade)}"
aria-label="${escapeSearchHtml(v.shade)}"
aria-selected="${active ? "true" : "false"}"
data-card-shade
data-slug="${escapeSearchHtml(v.slug)}"
data-image="${escapeSearchHtml(v.heroImage || "")}"
data-price="${v.price}"
data-compare="${v.compareAtPrice ?? ""}"
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 filterAttrs(p) {
const updated = p.updatedAt ? new Date(p.updatedAt).getTime() : 0;
const discount =
p.compareAtPrice && p.compareAtPrice > p.price
? Math.round((1 - p.price / p.compareAtPrice) * 100)
: 0;
const prices = [Number(p.price) || 0];
for (const v of p.colorVariants || []) {
const n = Number(v.price);
if (Number.isFinite(n)) prices.push(n);
}
const min = Math.min(...prices);
const max = Math.max(...prices);
return [
`data-category="${escapeSearchHtml(p.category || "")}"`,
`data-category-fa="${escapeSearchHtml(p.categoryFa || "")}"`,
`data-price="${p.price}"`,
`data-price-min="${min}"`,
`data-price-max="${max}"`,
`data-name="${escapeSearchHtml(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="${discount}"`,
].join(" ");
}
function productCardHtml(p) {
const title = displayName(p);
const href = `/products/${escapeSearchHtml(p.slug)}/`;
const discount =
p.compareAtPrice && p.compareAtPrice > p.price
? Math.round((1 - p.price / p.compareAtPrice) * 100)
: 0;
const badge =
discount >= 1 ? `<span class="offer-badge">−${discount}%</span>` : "";
const priceHtml =
discount >= 1
? `<p class="price price--offer" data-card-price>
<span class="price-now">${formatSearchPrice(p.price)}</span>
<span class="price-was">${formatSearchPrice(p.compareAtPrice)}</span>
</p>`
: `<p class="price" data-card-price>${formatSearchPrice(p.price)}</p>`;
return `<article class="product-card${discount >= 1 ? " product-card--offer" : ""}" data-product-card ${filterAttrs(p)}>
<a class="thumb" href="${href}" data-card-link>
${badge}
${stockBadge(p)}
${
p.heroImage
? `<img src="${escapeSearchHtml(p.heroImage)}" alt="${escapeSearchHtml(title)}" loading="lazy" data-card-image />`
: ""
}
</a>
<div class="meta">
${loveYourEyesPillHtml(p.slug)}
<p class="name"><a href="${href}" data-card-link>${escapeSearchHtml(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="${escapeSearchHtml(p.id)}" data-slug="${escapeSearchHtml(p.slug)}" data-name="${escapeSearchHtml(title)}" data-price="${p.price}" data-image="${escapeSearchHtml(p.heroImage || "")}" data-category="${escapeSearchHtml(p.category || "")}" data-category-fa="${escapeSearchHtml(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>`;
}
const params = new URLSearchParams(location.search);
const q = (params.get("q") || "").trim();
const titleEl = document.querySelector("[data-search-title]");
const resultsEl = document.querySelector("[data-search-results]");
const emptyEl = document.querySelector("[data-search-empty]");
const countEl = document.querySelector("[data-listing-count]");
const resultPill = document.querySelector("[data-filters-result-count]");
if (titleEl && q) titleEl.textContent = `نتایج برای «${q}»`;
try {
const products = await loadSearchCatalog();
const hits = searchCatalogProducts(products, q);
if (!resultsEl) {
/* no-op */
} else if (!hits.length) {
if (emptyEl) emptyEl.hidden = false;
resultsEl.innerHTML = "";
if (countEl) countEl.textContent = "۰";
if (resultPill) resultPill.textContent = "۰";
} else {
if (emptyEl) emptyEl.hidden = true;
resultsEl.innerHTML = hits.map(productCardHtml).join("");
const n = hits.length.toLocaleString("fa-IR");
if (countEl) countEl.textContent = n;
if (resultPill) resultPill.textContent = n;
initCardShadePickers();
refreshProductFilters();
}
} catch {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.querySelector("p") &&
(emptyEl.querySelector("p").textContent = "بارگذاری نتایج ممکن نشد.");
}
}
+149
View File
@@ -0,0 +1,149 @@
import {
displayName,
escapeSearchHtml,
formatSearchPrice,
loadSearchCatalog,
searchCatalogProducts,
} from "/js/search-catalog.js?v=persianCopyRtl2";
const PREVIEW_COUNT = 4;
const MIN_CHARS = 2;
const DEBOUNCE_MS = 180;
function debounce(fn, ms) {
let t = 0;
return (...args) => {
window.clearTimeout(t);
t = window.setTimeout(() => fn(...args), ms);
};
}
function suggestItemHtml(p) {
const title = displayName(p);
const href = `/products/${escapeSearchHtml(p.slug)}/`;
const img = p.heroImage
? `<img src="${escapeSearchHtml(p.heroImage)}" alt="" width="40" height="50" loading="lazy" />`
: `<span class="search-suggest-ph" aria-hidden="true"></span>`;
return `<a class="search-suggest-item" role="option" href="${href}">
${img}
<span class="search-suggest-meta">
<span class="search-suggest-name">${escapeSearchHtml(title)}</span>
<span class="search-suggest-price">${formatSearchPrice(p.price)}</span>
</span>
</a>`;
}
function bindSearchInput(input, panel) {
if (!input || !panel) return;
let products = null;
let active = -1;
loadSearchCatalog()
.then((list) => {
products = list;
})
.catch(() => {
products = [];
});
function close() {
panel.hidden = true;
panel.innerHTML = "";
active = -1;
input.setAttribute("aria-expanded", "false");
}
function open() {
panel.hidden = false;
input.setAttribute("aria-expanded", "true");
}
function render(q) {
if (!products) return;
const hits = searchCatalogProducts(products, q, PREVIEW_COUNT);
if (!hits.length) {
close();
return;
}
const allHref = `/search/?q=${encodeURIComponent(q.trim())}`;
panel.innerHTML =
hits.map(suggestItemHtml).join("") +
`<a class="search-suggest-all" href="${allHref}">مشاهده همه نتایج</a>`;
open();
active = -1;
}
const onInput = debounce(() => {
const q = input.value.trim();
if (q.length < MIN_CHARS) {
close();
return;
}
render(q);
}, DEBOUNCE_MS);
input.addEventListener("input", onInput);
input.addEventListener("focus", () => {
const q = input.value.trim();
if (q.length >= MIN_CHARS) render(q);
});
input.addEventListener("keydown", (e) => {
const items = [...panel.querySelectorAll(".search-suggest-item, .search-suggest-all")];
if (!items.length || panel.hidden) return;
if (e.key === "ArrowDown") {
e.preventDefault();
active = Math.min(active + 1, items.length - 1);
items.forEach((el, i) => el.classList.toggle("is-active", i === active));
items[active]?.scrollIntoView({ block: "nearest" });
} else if (e.key === "ArrowUp") {
e.preventDefault();
active = Math.max(active - 1, 0);
items.forEach((el, i) => el.classList.toggle("is-active", i === active));
items[active]?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && active >= 0) {
e.preventDefault();
items[active].click();
} else if (e.key === "Escape") {
close();
input.blur();
}
});
document.addEventListener("click", (e) => {
if (!(e.target instanceof Element)) return;
if (input.contains(e.target) || panel.contains(e.target)) return;
close();
});
}
export function initSearchSuggest() {
document.querySelectorAll(".search-shell").forEach((shell) => {
const input = shell.querySelector('input[type="search"]');
const panel = shell.querySelector("[data-search-suggest]");
bindSearchInput(input, panel);
});
document.querySelectorAll(".mobile-search").forEach((form) => {
const input = form.querySelector('input[type="search"]');
let panel = form.querySelector("[data-search-suggest]");
if (!panel) {
panel = document.createElement("div");
panel.className = "search-suggest search-suggest--mobile";
panel.setAttribute("data-search-suggest", "");
panel.hidden = true;
form.appendChild(panel);
}
bindSearchInput(input, panel);
});
const params = new URLSearchParams(location.search);
const q = params.get("q");
if (q && location.pathname.replace(/\/+$/, "").endsWith("/search")) {
document.querySelectorAll('.search-form input[name="q"], .mobile-search input[name="q"]').forEach((input) => {
input.value = q;
});
}
}