first deployment of kikomiko with url photos
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
8d98304137
commit
ae3ed7c7b7
Vendored
+444
@@ -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
|
||||
}
|
||||
Vendored
+951
@@ -0,0 +1,951 @@
|
||||
import { addItem, formatPrice } from "/js/cart.js";
|
||||
import {
|
||||
ensureEurRate,
|
||||
sellCompareFromItem,
|
||||
sellPriceFromItem,
|
||||
} from "/js/fx.js?v=fx1";
|
||||
|
||||
function moneyItem(product, variant) {
|
||||
const v = variant || {};
|
||||
return {
|
||||
price: v.price ?? product?.price,
|
||||
compareAtPrice: v.compareAtPrice ?? product?.compareAtPrice,
|
||||
priceEur: v.priceEur ?? product?.priceEur,
|
||||
sourcePrice: v.sourcePrice ?? product?.sourcePrice,
|
||||
sourceCurrency: v.sourceCurrency ?? product?.sourceCurrency,
|
||||
};
|
||||
}
|
||||
import { loveYourEyesPillHtml } from "/js/love-your-eyes.js?v=lyeOff";
|
||||
|
||||
export function initBuyButtons() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const btn = event.target instanceof Element
|
||||
? event.target.closest("[data-add-to-cart]")
|
||||
: null;
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
if (btn.hasAttribute("disabled")) return;
|
||||
|
||||
const productId = btn.getAttribute("data-product-id");
|
||||
const slug = btn.getAttribute("data-slug");
|
||||
const name = btn.getAttribute("data-name");
|
||||
const price = Number(btn.getAttribute("data-price"));
|
||||
const image = btn.getAttribute("data-image");
|
||||
const category =
|
||||
btn.getAttribute("data-category-fa") ||
|
||||
btn.getAttribute("data-category") ||
|
||||
btn.closest("[data-category-fa]")?.getAttribute("data-category-fa") ||
|
||||
btn.closest("[data-category]")?.getAttribute("data-category") ||
|
||||
null;
|
||||
if (!productId || !slug || !name || !Number.isFinite(price)) return;
|
||||
|
||||
addItem({ productId, slug, name, price, image, category });
|
||||
|
||||
const isIcon = btn.classList.contains("card-add-btn");
|
||||
if (isIcon) {
|
||||
btn.classList.add("is-added");
|
||||
window.clearTimeout(Number(btn.getAttribute("data-added-timer") || 0));
|
||||
const timer = window.setTimeout(() => {
|
||||
btn.classList.remove("is-added");
|
||||
}, 1200);
|
||||
btn.setAttribute("data-added-timer", String(timer));
|
||||
return;
|
||||
}
|
||||
|
||||
const label = btn.getAttribute("data-label-default") || btn.textContent || "";
|
||||
if (!btn.getAttribute("data-label-default")) {
|
||||
btn.setAttribute("data-label-default", label);
|
||||
}
|
||||
btn.textContent = "به سبد اضافه شد";
|
||||
btn.classList.add("is-added");
|
||||
window.clearTimeout(Number(btn.getAttribute("data-added-timer") || 0));
|
||||
const timer = window.setTimeout(() => {
|
||||
btn.textContent = btn.getAttribute("data-label-default") || label;
|
||||
btn.classList.remove("is-added");
|
||||
}, 1600);
|
||||
btn.setAttribute("data-added-timer", String(timer));
|
||||
});
|
||||
}
|
||||
|
||||
/** Native share on PDP (clipboard fallback). */
|
||||
export function initShareButtons() {
|
||||
document.addEventListener("click", async (event) => {
|
||||
const btn = event.target instanceof Element
|
||||
? event.target.closest("[data-share-product]")
|
||||
: null;
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
|
||||
const title = btn.getAttribute("data-share-title") || document.title;
|
||||
const url = location.href;
|
||||
const defaultLabel = btn.getAttribute("data-label-default") || btn.textContent || "اشتراکگذاری";
|
||||
if (!btn.getAttribute("data-label-default")) {
|
||||
btn.setAttribute("data-label-default", defaultLabel);
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title, url });
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
btn.textContent = "لینک کپی شد";
|
||||
btn.classList.add("is-shared");
|
||||
window.clearTimeout(Number(btn.getAttribute("data-share-timer") || 0));
|
||||
const timer = window.setTimeout(() => {
|
||||
btn.textContent = btn.getAttribute("data-label-default") || defaultLabel;
|
||||
btn.classList.remove("is-shared");
|
||||
}, 1600);
|
||||
btn.setAttribute("data-share-timer", String(timer));
|
||||
}
|
||||
} catch {
|
||||
/* user cancelled share sheet — ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function displayName(p) {
|
||||
const variants = p.colorVariants;
|
||||
let title = p.name;
|
||||
if (variants && variants.length >= 2) {
|
||||
const shade = variants.find((v) => v.slug === p.slug)?.shade;
|
||||
if (shade) {
|
||||
const trimmed = p.name.trim();
|
||||
if (trimmed.endsWith(shade)) title = trimmed.slice(0, -shade.length).trim();
|
||||
}
|
||||
}
|
||||
const fa = p.categoryFa || null;
|
||||
if (fa && !title.includes(fa)) return `\u2067${fa} · \u2066${title}\u2069\u2069`;
|
||||
return title;
|
||||
}
|
||||
|
||||
function shadeDots(p) {
|
||||
const variants = p.colorVariants;
|
||||
if (!variants || variants.length < 2) return "";
|
||||
const shown = variants.slice(0, 5);
|
||||
const extra = variants.length - shown.length;
|
||||
return `<div class="shade-dots" role="listbox" aria-label="رنگ">${shown
|
||||
.map((v) => {
|
||||
const active = v.slug === p.slug ? " is-active" : "";
|
||||
const bg = v.heroImage
|
||||
? `background-image:url('${escapeHtml(v.heroImage)}')`
|
||||
: "";
|
||||
const href = `/products/${escapeHtml(v.slug)}/`;
|
||||
return `<button
|
||||
type="button"
|
||||
class="shade-dot${active}"
|
||||
style="${bg}"
|
||||
title="${escapeHtml(v.shade)}"
|
||||
aria-label="${escapeHtml(v.shade)}"
|
||||
aria-selected="${active ? "true" : "false"}"
|
||||
data-card-shade
|
||||
data-slug="${escapeHtml(v.slug)}"
|
||||
data-image="${escapeHtml(v.heroImage || "")}"
|
||||
data-price="${sellPriceFromItem(moneyItem(p, v))}"
|
||||
data-compare="${sellCompareFromItem(moneyItem(p, v)) ?? ""}"
|
||||
data-href="${href}"
|
||||
></button>`;
|
||||
})
|
||||
.join("")}${
|
||||
extra > 0
|
||||
? `<span class="shade-dot-more">+${extra.toLocaleString("fa-IR")} عدد</span>`
|
||||
: ""
|
||||
}</div>`;
|
||||
}
|
||||
|
||||
function cardIsInStock(p) {
|
||||
const variants = p.colorVariants;
|
||||
if (variants && variants.length > 1) {
|
||||
return variants.some((v) => Number(v.stock) > 0);
|
||||
}
|
||||
return Number(p.stock) > 0;
|
||||
}
|
||||
|
||||
function stockBadge(p) {
|
||||
if (cardIsInStock(p)) return "";
|
||||
return `<span class="stock-badge stock-badge--oos">نا موجود</span>`;
|
||||
}
|
||||
|
||||
function cardHtml(p) {
|
||||
const title = displayName(p);
|
||||
const href = `/products/${escapeHtml(p.slug)}/`;
|
||||
const updated = p.updatedAt ? new Date(p.updatedAt).getTime() : 0;
|
||||
const sell = sellPriceFromItem(moneyItem(p, null));
|
||||
const sellCompare = sellCompareFromItem(moneyItem(p, null));
|
||||
const discount =
|
||||
sellCompare && sellCompare > sell
|
||||
? Math.round((1 - sell / sellCompare) * 100)
|
||||
: 0;
|
||||
let bestDiscount = discount;
|
||||
for (const v of p.colorVariants || []) {
|
||||
const vs = sellPriceFromItem(moneyItem(p, v));
|
||||
const vc = sellCompareFromItem(moneyItem(p, v));
|
||||
if (vc && vc > vs) {
|
||||
bestDiscount = Math.max(bestDiscount, Math.round((1 - vs / vc) * 100));
|
||||
}
|
||||
}
|
||||
const bounds = catalogPriceBounds(p);
|
||||
const priceMin = bounds.min;
|
||||
const priceMax = bounds.max;
|
||||
const attrs = [
|
||||
`data-category="${escapeHtml(p.category || "")}"`,
|
||||
`data-category-fa="${escapeHtml(p.categoryFa || "")}"`,
|
||||
`data-price="${sell}"`,
|
||||
`data-price-min="${priceMin}"`,
|
||||
`data-price-max="${priceMax}"`,
|
||||
`data-name="${escapeHtml(p.name)}"`,
|
||||
`data-updated="${Number.isFinite(updated) ? updated : 0}"`,
|
||||
`data-rating="${p.ratingAvg || 0}"`,
|
||||
`data-reviews="${p.reviewCount || 0}"`,
|
||||
`data-featured="${p.featured ? 1 : 0}"`,
|
||||
`data-in-stock="${cardIsInStock(p) ? 1 : 0}"`,
|
||||
`data-discount="${bestDiscount}"`,
|
||||
].join(" ");
|
||||
const badge =
|
||||
bestDiscount >= 1 ? `<span class="offer-badge">−${bestDiscount}%</span>` : "";
|
||||
const priceHtml =
|
||||
discount >= 1
|
||||
? `<p class="price price--offer" data-card-price>
|
||||
<span class="price-now">${formatPrice(sell)}</span>
|
||||
<span class="price-was">${formatPrice(sellCompare)}</span>
|
||||
</p>`
|
||||
: `<p class="price" data-card-price>${formatPrice(sell)}</p>`;
|
||||
return `<article class="product-card${bestDiscount >= 1 ? " product-card--offer" : ""}" data-product-card ${attrs}>
|
||||
<a class="thumb" href="${href}" data-card-link>
|
||||
${badge}
|
||||
${stockBadge(p)}
|
||||
${
|
||||
p.heroImage
|
||||
? `<img src="${escapeHtml(p.heroImage)}" alt="${escapeHtml(title)}" loading="lazy" data-card-image />`
|
||||
: ""
|
||||
}</a>
|
||||
<div class="meta">
|
||||
${loveYourEyesPillHtml(p.slug)}
|
||||
<p class="name"><a href="${href}" data-card-link>${escapeHtml(title)}</a></p>
|
||||
${shadeDots(p)}
|
||||
<div class="card-action-row">
|
||||
${priceHtml}
|
||||
<button type="button" class="card-add-btn" data-add-to-cart data-product-id="${escapeHtml(p.id)}" data-slug="${escapeHtml(p.slug)}" data-name="${escapeHtml(title)}" data-price="${sell}" data-image="${escapeHtml(p.heroImage || "")}" data-category="${escapeHtml(p.category || "")}" data-category-fa="${escapeHtml(p.categoryFa || "")}" aria-label="افزودن به سبد خرید">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function catalogPriceBounds(p) {
|
||||
const prices = [sellPriceFromItem(moneyItem(p, null))];
|
||||
for (const v of p.colorVariants || []) {
|
||||
const n = sellPriceFromItem(moneyItem(p, v));
|
||||
if (Number.isFinite(n)) prices.push(n);
|
||||
}
|
||||
return { min: Math.min(...prices), max: Math.max(...prices) };
|
||||
}
|
||||
|
||||
function sortCatalog(products, sort) {
|
||||
const list = [...products];
|
||||
switch (sort) {
|
||||
case "price-asc":
|
||||
return list.sort(
|
||||
(a, b) => catalogPriceBounds(a).min - catalogPriceBounds(b).min
|
||||
);
|
||||
case "price-desc":
|
||||
return list.sort(
|
||||
(a, b) => catalogPriceBounds(b).max - catalogPriceBounds(a).max
|
||||
);
|
||||
case "best-rated":
|
||||
return list.sort((a, b) => (b.ratingAvg || 0) - (a.ratingAvg || 0));
|
||||
case "most-reviewed":
|
||||
return list.sort((a, b) => (b.reviewCount || 0) - (a.reviewCount || 0));
|
||||
case "featured":
|
||||
return list.sort((a, b) => Number(b.featured) - Number(a.featured));
|
||||
case "name-asc":
|
||||
return list.sort((a, b) => a.name.localeCompare(b.name, "fa"));
|
||||
case "newest":
|
||||
default:
|
||||
return list.sort(
|
||||
(a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseImagesAttr(raw, fallbackImage) {
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length) {
|
||||
return parsed.filter((src) => typeof src === "string" && src);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return fallbackImage ? [fallbackImage] : [];
|
||||
}
|
||||
|
||||
function updatePdpDiscountBadge(discount) {
|
||||
document.querySelectorAll("[data-pdp-discount-badge]").forEach((el) => {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
if (discount != null && discount >= 1) {
|
||||
el.textContent = `−${discount}%`;
|
||||
el.hidden = false;
|
||||
el.classList.add("offer-badge");
|
||||
} else {
|
||||
el.textContent = "";
|
||||
el.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pdpGalleryMarkup(images) {
|
||||
const gallery = images.filter((src) => typeof src === "string" && src);
|
||||
if (!gallery.length) return "";
|
||||
return gallery
|
||||
.map(
|
||||
(src, i) => `<figure class="pdp-gallery-shot">
|
||||
<img src="${escapeHtml(src)}" alt="" loading="${i === 0 ? "eager" : "lazy"}" />
|
||||
</figure>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function splitMainAndRest(images, hero) {
|
||||
const all = (images || []).filter((src) => typeof src === "string" && src);
|
||||
const list = all.length ? all : hero ? [hero] : [];
|
||||
const main = hero || list[0] || "";
|
||||
const rest = [];
|
||||
let skippedMain = false;
|
||||
for (const src of list) {
|
||||
if (!skippedMain && main && src === main) {
|
||||
skippedMain = true;
|
||||
continue;
|
||||
}
|
||||
rest.push(src);
|
||||
}
|
||||
return { main, rest };
|
||||
}
|
||||
|
||||
function updateVariantGallery(images, hero) {
|
||||
const { main, rest } = splitMainAndRest(images, hero);
|
||||
if (!main && !rest.length) return;
|
||||
|
||||
const buyHeroImg = document.querySelector("[data-buy-hero-img]");
|
||||
const buyHeroWrap = document.querySelector("[data-buy-hero]");
|
||||
if (buyHeroImg && main) {
|
||||
buyHeroImg.setAttribute("src", main);
|
||||
if (buyHeroWrap instanceof HTMLElement) buyHeroWrap.hidden = false;
|
||||
} else if (buyHeroWrap instanceof HTMLElement) {
|
||||
buyHeroWrap.hidden = true;
|
||||
}
|
||||
|
||||
const grids = document.querySelectorAll("[data-variant-gallery]");
|
||||
if (grids.length) {
|
||||
const html = pdpGalleryMarkup(rest);
|
||||
grids.forEach((grid) => {
|
||||
grid.innerHTML = html;
|
||||
});
|
||||
const shell = document.querySelector("[data-pdp-hero-wrap]");
|
||||
if (shell instanceof HTMLElement) shell.hidden = !html;
|
||||
} else {
|
||||
const first = document.querySelector(
|
||||
".pdp-gallery-shot img, .gallery-grid img, .pdp-hero img"
|
||||
);
|
||||
if (first) first.setAttribute("src", main || rest[0] || "");
|
||||
}
|
||||
|
||||
const heroImg = document.querySelector(".pdp-hero img");
|
||||
if (heroImg) heroImg.setAttribute("src", main || rest[0] || "");
|
||||
}
|
||||
|
||||
function applyShadeSelection(btn) {
|
||||
const panel =
|
||||
btn.closest("[data-buy-panel]") ||
|
||||
document.querySelector("[data-buy-panel]");
|
||||
if (!panel) return;
|
||||
|
||||
const id = btn.getAttribute("data-id");
|
||||
const slug = btn.getAttribute("data-slug");
|
||||
const name = btn.getAttribute("data-name");
|
||||
const shade = btn.getAttribute("data-shade") || "";
|
||||
const intro = btn.getAttribute("data-intro") || "";
|
||||
const price = Number(btn.getAttribute("data-price"));
|
||||
const compareRaw = btn.getAttribute("data-compare");
|
||||
const compare = compareRaw ? Number(compareRaw) : null;
|
||||
const image = btn.getAttribute("data-image") || "";
|
||||
const images = parseImagesAttr(btn.getAttribute("data-images"), image);
|
||||
const stock = Number(btn.getAttribute("data-stock") || 0);
|
||||
const low = Number(panel.getAttribute("data-low-stock") || 5);
|
||||
const labelAdd = panel.getAttribute("data-label-add") || "افزودن به سبد";
|
||||
const labelOos = panel.getAttribute("data-label-oos") || "ناموجود";
|
||||
const labelLimited = panel.getAttribute("data-label-limited") || "";
|
||||
const labelSold = panel.getAttribute("data-label-sold") || "";
|
||||
|
||||
const shadeRoot =
|
||||
btn.closest("[data-shade-picker]") ||
|
||||
document.querySelector("[data-pdp-shades] [data-shade-picker]") ||
|
||||
panel;
|
||||
shadeRoot.querySelectorAll("[data-shade-select]").forEach((el) => {
|
||||
const on = el === btn;
|
||||
el.classList.toggle("is-active", on);
|
||||
el.setAttribute("aria-selected", on ? "true" : "false");
|
||||
if (on) el.setAttribute("aria-current", "true");
|
||||
else el.removeAttribute("aria-current");
|
||||
});
|
||||
|
||||
const shadeLabel =
|
||||
shadeRoot.querySelector("[data-shade-label]") ||
|
||||
panel.querySelector("[data-shade-label]");
|
||||
if (shadeLabel) shadeLabel.textContent = shade;
|
||||
|
||||
const title = panel.querySelector("[data-buy-title]");
|
||||
if (title && name) title.textContent = name;
|
||||
const introEl = document.querySelector("[data-product-description-intro]");
|
||||
if (introEl && intro) introEl.textContent = intro;
|
||||
const factsShade = document.querySelector('[data-product-fact="shade"]');
|
||||
if (factsShade) factsShade.textContent = shade;
|
||||
if (name) document.title = `${name} | کیکومیکو`;
|
||||
|
||||
const priceRow = panel.querySelector("[data-buy-price]");
|
||||
if (priceRow && Number.isFinite(price)) {
|
||||
const discount =
|
||||
compare && compare > price
|
||||
? Math.round(((compare - price) / compare) * 100)
|
||||
: null;
|
||||
priceRow.innerHTML = `<span class="now">${escapeHtml(formatPrice(price))}</span>${
|
||||
discount != null
|
||||
? `<span class="was">${escapeHtml(formatPrice(compare))}</span><span class="offer-badge offer-badge--buy" data-buy-discount>−${discount}%</span>`
|
||||
: ""
|
||||
}`;
|
||||
updatePdpDiscountBadge(discount);
|
||||
}
|
||||
|
||||
const stockEl = panel.querySelector("[data-buy-stock]");
|
||||
if (stockEl) {
|
||||
if (stock <= 0) stockEl.textContent = labelSold;
|
||||
else if (stock <= low) stockEl.textContent = `${labelLimited} ${stock}`;
|
||||
else stockEl.textContent = "";
|
||||
}
|
||||
|
||||
const syncCartBtn = (el) => {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.setAttribute("data-product-id", id || "");
|
||||
el.setAttribute("data-slug", slug || "");
|
||||
el.setAttribute("data-name", name || "");
|
||||
el.setAttribute("data-price", String(price));
|
||||
el.setAttribute("data-image", image);
|
||||
const catFa =
|
||||
panel.getAttribute("data-category-fa") ||
|
||||
panel.getAttribute("data-category") ||
|
||||
"";
|
||||
const cat = panel.getAttribute("data-category") || "";
|
||||
if (catFa) el.setAttribute("data-category-fa", catFa);
|
||||
if (cat) el.setAttribute("data-category", cat);
|
||||
if (stock > 0) {
|
||||
el.removeAttribute("disabled");
|
||||
if (!el.classList.contains("is-added")) {
|
||||
el.textContent = labelAdd;
|
||||
el.setAttribute("data-label-default", labelAdd);
|
||||
}
|
||||
} else {
|
||||
el.setAttribute("disabled", "");
|
||||
el.textContent = labelOos;
|
||||
el.setAttribute("data-label-default", labelOos);
|
||||
}
|
||||
};
|
||||
|
||||
panel.querySelectorAll("[data-add-to-cart]").forEach(syncCartBtn);
|
||||
document
|
||||
.querySelectorAll(".sticky-buy [data-add-to-cart]")
|
||||
.forEach(syncCartBtn);
|
||||
|
||||
const stickyName = document.querySelector(".sticky-buy [data-sticky-name]");
|
||||
const stickyPrice = document.querySelector(".sticky-buy [data-sticky-price]");
|
||||
if (stickyName && name) stickyName.textContent = name;
|
||||
if (stickyPrice && Number.isFinite(price))
|
||||
stickyPrice.textContent = formatPrice(price);
|
||||
|
||||
updateVariantGallery(images, image);
|
||||
|
||||
if (shade) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("shade", shade);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
url.pathname + "?" + url.searchParams.toString() + url.hash
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function initShadePicker() {
|
||||
const picker = document.querySelector("[data-shade-picker]");
|
||||
if (!picker) return;
|
||||
|
||||
picker.querySelectorAll("[data-shade-select]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => applyShadeSelection(btn));
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const wanted = params.get("shade");
|
||||
if (!wanted) return;
|
||||
const match = [...picker.querySelectorAll("[data-shade-select]")].find(
|
||||
(el) => (el.getAttribute("data-shade") || "") === wanted
|
||||
);
|
||||
if (match && !match.classList.contains("is-active")) {
|
||||
applyShadeSelection(match);
|
||||
}
|
||||
}
|
||||
|
||||
/** Product-list shade dots: swap card image/price/link without navigating. */
|
||||
export function initCardShadePickers() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const btn = target.closest("[data-card-shade]");
|
||||
if (!btn) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const card = btn.closest("[data-product-card]");
|
||||
if (!card) return;
|
||||
|
||||
const image = btn.getAttribute("data-image") || "";
|
||||
const href = btn.getAttribute("data-href") || "";
|
||||
const price = Number(btn.getAttribute("data-price"));
|
||||
const compareRaw = btn.getAttribute("data-compare");
|
||||
const compare = compareRaw ? Number(compareRaw) : null;
|
||||
|
||||
card.querySelectorAll("[data-card-shade]").forEach((el) => {
|
||||
const on = el === btn;
|
||||
el.classList.toggle("is-active", on);
|
||||
el.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
|
||||
const img = card.querySelector("[data-card-image]");
|
||||
if (img && image) img.setAttribute("src", image);
|
||||
|
||||
if (href) {
|
||||
card.querySelectorAll("[data-card-link]").forEach((link) => {
|
||||
link.setAttribute("href", href);
|
||||
});
|
||||
}
|
||||
|
||||
const priceEl = card.querySelector("[data-card-price]");
|
||||
if (priceEl && Number.isFinite(price)) {
|
||||
card.setAttribute("data-price", String(price));
|
||||
const addBtn = card.querySelector("[data-add-to-cart]");
|
||||
if (addBtn) addBtn.setAttribute("data-price", String(price));
|
||||
|
||||
const discount =
|
||||
compare && compare > price
|
||||
? Math.round(((compare - price) / compare) * 100)
|
||||
: 0;
|
||||
if (discount >= 1) {
|
||||
priceEl.classList.add("price--offer");
|
||||
priceEl.innerHTML = `<span class="price-now">${escapeHtml(formatPrice(price))}</span><span class="price-was">${escapeHtml(formatPrice(compare))}</span>`;
|
||||
} else {
|
||||
priceEl.classList.remove("price--offer");
|
||||
priceEl.textContent = formatPrice(price);
|
||||
}
|
||||
|
||||
const thumb = card.querySelector(".thumb");
|
||||
let badge = card.querySelector(".offer-badge");
|
||||
if (discount >= 1) {
|
||||
if (!badge && thumb) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "offer-badge";
|
||||
thumb.prepend(badge);
|
||||
}
|
||||
if (badge) badge.textContent = `−${discount}%`;
|
||||
card.classList.add("product-card--offer");
|
||||
card.setAttribute("data-discount", String(discount));
|
||||
} else {
|
||||
badge?.remove();
|
||||
card.classList.remove("product-card--offer");
|
||||
card.setAttribute("data-discount", "0");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initSortForms() {
|
||||
const collection = document.querySelector("[data-static-collection]");
|
||||
document.querySelectorAll("[data-sort-select]").forEach((select) => {
|
||||
select.addEventListener("change", async () => {
|
||||
// Client-mode listings sort in-place via filters.js — never hard-navigate.
|
||||
const listing = select.closest("[data-product-listing]");
|
||||
if (listing?.getAttribute("data-client-mode") === "1") return;
|
||||
|
||||
if (!collection) {
|
||||
const form = select.closest("form");
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
const qs = new URLSearchParams(new FormData(form));
|
||||
if (qs.get("sort") === "newest") qs.delete("sort");
|
||||
const hash = form.getAttribute("data-hash");
|
||||
location.assign(
|
||||
location.pathname +
|
||||
(qs.toString() ? `?${qs}` : "") +
|
||||
(hash ? `#${hash}` : "")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = collection.querySelector("[data-product-grid]");
|
||||
const countEl =
|
||||
collection.querySelector("[data-listing-count]") ||
|
||||
collection.querySelector("[data-product-count]");
|
||||
if (!grid) return;
|
||||
try {
|
||||
const res = await fetch("/data/catalog.dedupe2.json");
|
||||
const data = await res.json();
|
||||
const sorted = sortCatalog(data.products || [], select.value);
|
||||
const limit = Number(collection.getAttribute("data-home-limit") || 0);
|
||||
const shown = limit > 0 ? sorted.slice(0, limit) : sorted;
|
||||
grid.innerHTML = shown.map(cardHtml).join("");
|
||||
if (countEl) countEl.textContent = shown.length.toLocaleString("fa-IR");
|
||||
const pill = collection.querySelector("[data-filters-result-count]");
|
||||
if (pill) pill.textContent = shown.length.toLocaleString("fa-IR");
|
||||
const hash = select.closest("form")?.getAttribute("data-hash");
|
||||
if (hash) location.hash = hash;
|
||||
initCardShadePickers();
|
||||
} catch {
|
||||
/* keep existing grid */
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ROOT_SLUG_TO_CAT = {
|
||||
"make-up": "MAKE UP",
|
||||
"skin-care": "SKIN CARE",
|
||||
fragrances: "FRAGRANCES",
|
||||
accessories: "ACCESSORIES",
|
||||
};
|
||||
|
||||
function normalizeCatText(value) {
|
||||
return String(value || "")
|
||||
.toUpperCase()
|
||||
.replace(/[-\s]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pathCategoryNeedle() {
|
||||
const path = location.pathname;
|
||||
let m = path.match(/\/category\/([^/]+)\/sub\/([^/]+)/);
|
||||
if (m) return { root: m[1], leaf: m[2], kind: "sub" };
|
||||
m = path.match(/\/category\/([^/]+)\/type\/([^/]+)/);
|
||||
if (m) return { root: m[1], leaf: m[2], kind: "type" };
|
||||
m = path.match(/\/category\/([^/]+)/);
|
||||
if (m) return { root: m[1], leaf: "", kind: "root" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function slugifyCategorySegment(part) {
|
||||
return String(part || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function categoryPathParts(category) {
|
||||
return String(category || "")
|
||||
.split("/")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesCategoryRoot(p, rootSlug) {
|
||||
const lev1 = ROOT_SLUG_TO_CAT[rootSlug];
|
||||
if (!lev1) return true;
|
||||
// Match first path segment only — avoids MAKE-UP BAG matching make-up.
|
||||
const first = normalizeCatText(categoryPathParts(p.category)[0] || "");
|
||||
return first === normalizeCatText(lev1);
|
||||
}
|
||||
|
||||
function productMatchesSubSlug(categoryRaw, subSlug) {
|
||||
if (!subSlug) return true;
|
||||
const leaf = subSlug.toLowerCase();
|
||||
const slugParts = categoryPathParts(categoryRaw).map(slugifyCategorySegment);
|
||||
// Exact segment match (mid or leaf): eyes-make-up, face-skin-care, brushes, …
|
||||
if (slugParts.includes(leaf)) return true;
|
||||
// Simple pluralization on the last segment only (brush ↔ brushes).
|
||||
const last = slugParts[slugParts.length - 1] || "";
|
||||
if (last === `${leaf}s` || leaf === `${last}s`) return true;
|
||||
const subNorm = normalizeCatText(subSlug.replace(/-/g, " "));
|
||||
const catNorm = normalizeCatText(categoryRaw);
|
||||
// Full sub-label substring (not a short root prefix like "skin care").
|
||||
if (subNorm.length >= 4 && catNorm.includes(subNorm)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function productMatchesTypeSlug(p, typeSlug) {
|
||||
if (!typeSlug) return true;
|
||||
const parts = categoryPathParts(p.category);
|
||||
const lastSlug = slugifyCategorySegment(parts[parts.length - 1] || "");
|
||||
const t = typeSlug.toLowerCase();
|
||||
if (
|
||||
lastSlug === t ||
|
||||
lastSlug.startsWith(`${t}s`) ||
|
||||
t.startsWith(lastSlug.replace(/s$/, ""))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const name = String(p.name || "").toLowerCase();
|
||||
const keywords = {
|
||||
foundation: ["foundation", "skin tone", "skin tint"],
|
||||
concealer: ["concealer"],
|
||||
mascara: ["mascara"],
|
||||
"lip-gloss": ["lip gloss", "lipgloss", "lip oil"],
|
||||
blush: ["blush"],
|
||||
"eye-pencil": ["eye pencil", "kajal", "kohl"],
|
||||
};
|
||||
const list = keywords[typeSlug];
|
||||
return Boolean(list?.some((k) => name.includes(k)));
|
||||
}
|
||||
|
||||
function productMatchesListing(p, listing, wrap) {
|
||||
const mode =
|
||||
listing?.getAttribute("data-listing-mode") ||
|
||||
(wrap?.hasAttribute("data-home-limit") ? "home" : "") ||
|
||||
"";
|
||||
if (mode === "offers") {
|
||||
let best = 0;
|
||||
if (p.compareAtPrice && p.compareAtPrice > p.price) {
|
||||
best = Math.round((1 - p.price / p.compareAtPrice) * 100);
|
||||
}
|
||||
for (const v of p.colorVariants || []) {
|
||||
if (v.compareAtPrice && v.compareAtPrice > v.price) {
|
||||
best = Math.max(
|
||||
best,
|
||||
Math.round((1 - v.price / v.compareAtPrice) * 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
return best >= 30;
|
||||
}
|
||||
if (mode === "category") {
|
||||
const pathNeedle = pathCategoryNeedle();
|
||||
const rootSlug =
|
||||
listing.getAttribute("data-category-slug") ||
|
||||
pathNeedle?.root ||
|
||||
"";
|
||||
if (!matchesCategoryRoot(p, rootSlug)) return false;
|
||||
if (pathNeedle?.kind === "type" && pathNeedle.leaf) {
|
||||
return productMatchesTypeSlug(p, pathNeedle.leaf);
|
||||
}
|
||||
const urlLeaf = pathNeedle?.leaf || "";
|
||||
if (urlLeaf) return productMatchesSubSlug(p.category, urlLeaf);
|
||||
const subLabel = listing.getAttribute("data-current-sub") || "";
|
||||
if (subLabel && !/^[a-z0-9-]+$/i.test(subLabel)) {
|
||||
const labelNorm = normalizeCatText(subLabel);
|
||||
const catFa = normalizeCatText(p.categoryFa || "");
|
||||
if (labelNorm && (normalizeCatText(p.category).includes(labelNorm) || catFa.includes(labelNorm))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Empty category exports ship without a grid — inject one before hydration. */
|
||||
function ensureProductGrid(wrap, listing) {
|
||||
let grid =
|
||||
wrap.querySelector?.("[data-product-grid]") ||
|
||||
listing?.closest?.(".page-pad")?.querySelector?.("[data-product-grid]");
|
||||
if (grid) return grid;
|
||||
const pagePad = listing?.closest?.(".page-pad") || wrap.closest?.(".page-pad");
|
||||
if (!pagePad) return null;
|
||||
pagePad.querySelectorAll(".container-luxury.section-padding").forEach((el) => {
|
||||
if (/محصولی در این دسته/.test(el.textContent || "")) el.remove();
|
||||
});
|
||||
grid = document.createElement("div");
|
||||
grid.className = "container-luxury list-grid";
|
||||
grid.setAttribute("data-product-grid", "");
|
||||
const guide = pagePad.querySelector(
|
||||
"section.container-luxury.section-padding, section.section-padding, section.editorial-guide"
|
||||
);
|
||||
if (guide) pagePad.insertBefore(grid, guide);
|
||||
else pagePad.appendChild(grid);
|
||||
return grid;
|
||||
}
|
||||
|
||||
function megaHrefSubSlug(href) {
|
||||
const m = String(href || "").match(/\/sub\/([^/]+)/);
|
||||
return m?.[1]?.toLowerCase() || "";
|
||||
}
|
||||
|
||||
function countProductsForHref(products, href) {
|
||||
const rootMatch = String(href || "").match(/\/category\/([^/]+)/);
|
||||
const root = rootMatch?.[1] || "";
|
||||
const sub = megaHrefSubSlug(href);
|
||||
return products.filter((p) => {
|
||||
if (!matchesCategoryRoot(p, root)) return false;
|
||||
return sub ? productMatchesSubSlug(p.category, sub) : true;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function faCount(n) {
|
||||
return `(${n.toLocaleString("fa-IR")})`;
|
||||
}
|
||||
|
||||
/** Fill empty fragrance mega/mobile panes when catalog has FRAGRANCES products. */
|
||||
function hydrateEmptyFragranceMenus(products) {
|
||||
const n = products.filter((p) => matchesCategoryRoot(p, "fragrances")).length;
|
||||
if (n < 1) return;
|
||||
const countText = faCount(n);
|
||||
|
||||
document
|
||||
.querySelectorAll('.mega-pane[data-mega-pane="fragrances"] .mega-children')
|
||||
.forEach((ul) => {
|
||||
const empty = [...ul.querySelectorAll("li.text-muted")].find((li) =>
|
||||
/هنوز محصولی/.test(li.textContent || "")
|
||||
);
|
||||
if (!empty) return;
|
||||
empty.outerHTML = `<li class="mega-child">
|
||||
<span class="mega-idx">|01|</span>
|
||||
<a href="/category/fragrances/">عطر<span class="mega-count">${countText}</span></a>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
document
|
||||
.querySelectorAll('.mobile-sub[data-mobile-sub="fragrances"]')
|
||||
.forEach((sub) => {
|
||||
if (sub.querySelector("a[href*='/category/fragrances']")) return;
|
||||
sub.innerHTML = `<a href="/category/fragrances/">عطر <span>${countText}</span></a>`;
|
||||
const row = sub.closest(".mobile-cat")?.querySelector(".mobile-cat-row");
|
||||
if (row && !row.querySelector("[data-mobile-expand]")) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.setAttribute("data-mobile-expand", "fragrances");
|
||||
btn.setAttribute("aria-label", "زیرمجموعههای عطر");
|
||||
btn.innerHTML =
|
||||
'<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';
|
||||
row.appendChild(btn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh (0) placeholders in the mega + mobile menus from the live catalog. */
|
||||
function hydrateMegaMenuCounts(products) {
|
||||
hydrateEmptyFragranceMenus(products);
|
||||
|
||||
document.querySelectorAll(".mega-child a[href*='/category/']").forEach((link) => {
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
const countEl = link.querySelector(".mega-count");
|
||||
if (!countEl) return;
|
||||
const n = countProductsForHref(products, link.getAttribute("href"));
|
||||
countEl.textContent = faCount(n);
|
||||
});
|
||||
|
||||
// Mobile drawer uses bare <span>(0)</span> without .mega-count
|
||||
document.querySelectorAll(".mobile-sub a[href*='/category/']").forEach((link) => {
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
const span = link.querySelector("span");
|
||||
if (!span) return;
|
||||
const n = countProductsForHref(products, link.getAttribute("href"));
|
||||
span.textContent = faCount(n);
|
||||
});
|
||||
|
||||
// Filter sheet data-count attrs (used by estimateCount fallback)
|
||||
document
|
||||
.querySelectorAll('[data-filter-option="category"][data-count]')
|
||||
.forEach((btn) => {
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const href = btn.getAttribute("data-href") || "";
|
||||
if (!href.includes("/category/")) return;
|
||||
const n = countProductsForHref(products, href);
|
||||
btn.setAttribute("data-count", String(n));
|
||||
const countSpan = btn.querySelector(".filters-option-count");
|
||||
if (countSpan) countSpan.textContent = n.toLocaleString("fa-IR");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Design-export pages ship empty product grids. Hydrate them from
|
||||
* /data/catalog.dedupe2.json so the CMS export actually appears.
|
||||
* Mega-menu counts always refresh — even when grids already have cards
|
||||
* (homepage) — so (0) placeholders are not left behind after a catalog swap.
|
||||
*/
|
||||
export async function initStaticCatalogHydration() {
|
||||
await ensureEurRate();
|
||||
const wraps = [
|
||||
...document.querySelectorAll("[data-static-collection]"),
|
||||
...document.querySelectorAll(
|
||||
'[data-product-listing][data-client-mode="1"]'
|
||||
),
|
||||
];
|
||||
const targets = [];
|
||||
const seen = new Set();
|
||||
for (const el of wraps) {
|
||||
const wrap =
|
||||
el.closest(".collection-wrap") ||
|
||||
el.closest(".page-pad") ||
|
||||
el.parentElement ||
|
||||
el;
|
||||
let grid =
|
||||
wrap.querySelector?.("[data-product-grid]") ||
|
||||
el.querySelector?.("[data-product-grid]");
|
||||
const listing =
|
||||
wrap.querySelector?.("[data-product-listing]") ||
|
||||
el.closest?.("[data-product-listing]") ||
|
||||
el;
|
||||
if (!grid && listing?.getAttribute("data-listing-mode") === "category") {
|
||||
grid = ensureProductGrid(wrap, listing);
|
||||
}
|
||||
if (!grid || seen.has(grid)) continue;
|
||||
if (grid.querySelector("[data-product-card]")) continue;
|
||||
// Search page has its own loader
|
||||
if (grid.hasAttribute("data-search-results")) continue;
|
||||
seen.add(grid);
|
||||
targets.push({ wrap, grid, listing });
|
||||
}
|
||||
|
||||
let products = [];
|
||||
try {
|
||||
const res = await fetch("/data/catalog.dedupe2.json");
|
||||
if (!res.ok) throw new Error("catalog fetch failed");
|
||||
const data = await res.json();
|
||||
products = data.products || [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always refresh menu counts from the live catalog (homepage included).
|
||||
hydrateMegaMenuCounts(products);
|
||||
if (!targets.length) return;
|
||||
|
||||
for (const { wrap, grid, listing } of targets) {
|
||||
const sort =
|
||||
listing?.getAttribute("data-current-sort") ||
|
||||
new URLSearchParams(location.search).get("sort") ||
|
||||
"newest";
|
||||
let list = products.filter((p) =>
|
||||
productMatchesListing(p, listing, wrap)
|
||||
);
|
||||
list = sortCatalog(list, sort);
|
||||
const limit = Number(
|
||||
wrap.getAttribute?.("data-home-limit") ||
|
||||
listing?.closest?.("[data-home-limit]")?.getAttribute("data-home-limit") ||
|
||||
0
|
||||
);
|
||||
const shown = limit > 0 ? list.slice(0, limit) : list;
|
||||
grid.innerHTML = shown.map(cardHtml).join("");
|
||||
const countEl =
|
||||
listing?.querySelector?.("[data-listing-count]") ||
|
||||
wrap.querySelector?.("[data-listing-count]");
|
||||
if (countEl) countEl.textContent = shown.length.toLocaleString("fa-IR");
|
||||
const pill = listing?.querySelector?.("[data-filters-result-count]");
|
||||
if (pill) pill.textContent = shown.length.toLocaleString("fa-IR");
|
||||
}
|
||||
initCardShadePickers();
|
||||
}
|
||||
|
||||
export { formatPrice };
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
hydrateCart,
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
formatPrice,
|
||||
} from "/js/cart.js";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
/** 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());
|
||||
}
|
||||
Vendored
+101
@@ -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)} تومان`;
|
||||
}
|
||||
Vendored
+594
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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®ion=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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+136
@@ -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();
|
||||
}
|
||||
Vendored
+460
@@ -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);
|
||||
});
|
||||
}
|
||||
Vendored
+52
@@ -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 = "";
|
||||
});
|
||||
}
|
||||
Vendored
+180
@@ -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;
|
||||
}
|
||||
Vendored
+316
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+580
@@ -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 };
|
||||
}
|
||||
Vendored
+296
@@ -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: ["یزد", "میبد", "اردکان", "بافق", "مهریز", "ابرکوه", "تفت"],
|
||||
},
|
||||
];
|
||||
Vendored
+96
@@ -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 "";
|
||||
}
|
||||
Vendored
+36
@@ -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();
|
||||
})();
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
// Product-owned text is escaped; imported HTML is never executed.
|
||||
const esc = (value) =>
|
||||
String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
Vendored
+308
@@ -0,0 +1,308 @@
|
||||
import { formatPrice, initShadePicker } from "/js/buy.js?v=pdpFx1";
|
||||
import { renderProductContent } from "/js/product-content.js?v=content5";
|
||||
import {
|
||||
ensureEurRate,
|
||||
sellCompareFromItem,
|
||||
sellPriceFromItem,
|
||||
} from "/js/fx.js?v=fx1";
|
||||
|
||||
function moneyItem(product, variant) {
|
||||
const v = variant || {};
|
||||
return {
|
||||
price: v.price ?? product?.price,
|
||||
compareAtPrice: v.compareAtPrice ?? product?.compareAtPrice,
|
||||
priceEur: v.priceEur ?? product?.priceEur,
|
||||
sourcePrice: v.sourcePrice ?? product?.sourcePrice,
|
||||
sourceCurrency: v.sourceCurrency ?? product?.sourceCurrency,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function displayTitle(p) {
|
||||
const variants = p.colorVariants;
|
||||
let title = p.name;
|
||||
if (variants && variants.length >= 2) {
|
||||
const shade = variants.find((v) => v.slug === p.slug)?.shade;
|
||||
if (shade) {
|
||||
const trimmed = p.name.trim();
|
||||
if (trimmed.endsWith(shade)) title = trimmed.slice(0, -shade.length).trim();
|
||||
}
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function uniqueImages(list) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const src of list || []) {
|
||||
if (typeof src !== "string" || !src || seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
out.push(src);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function variantGalleryImages(v) {
|
||||
if (!v) return [];
|
||||
if (Array.isArray(v.images) && v.images.length) {
|
||||
return uniqueImages(v.images);
|
||||
}
|
||||
return v.heroImage ? [v.heroImage] : [];
|
||||
}
|
||||
|
||||
/** Prefer shade gallery; fill from family images so PDP always looks like local multi-photo layout. */
|
||||
function productGalleryImages(product, variant) {
|
||||
const variantImgs = variantGalleryImages(variant);
|
||||
const familyImgs = uniqueImages([
|
||||
...(product?.heroImage ? [product.heroImage] : []),
|
||||
...((product?.images || []).filter((x) => typeof x === "string" && x)),
|
||||
]);
|
||||
if (variantImgs.length >= 2) return variantImgs;
|
||||
return uniqueImages([...variantImgs, ...familyImgs]);
|
||||
}
|
||||
|
||||
function inStock(p) {
|
||||
const variants = p.colorVariants;
|
||||
if (variants && variants.length > 1) {
|
||||
return variants.some((v) => Number(v.stock) > 0);
|
||||
}
|
||||
return Number(p.stock) > 0;
|
||||
}
|
||||
|
||||
function activeVariant(p) {
|
||||
const variants = p.colorVariants || [];
|
||||
const wanted = new URLSearchParams(location.search).get('shade');
|
||||
return variants.find(v=>wanted && v.shade===wanted) || variants.find((v) => v.slug === productSlugFromPath()) || variants.find(v=>v.id===p.defaultVariantId) || variants.find((v) => v.slug === p.slug) || variants[0] || null;
|
||||
}
|
||||
|
||||
function shadePickerHtml(p) {
|
||||
const variants = p.colorVariants || [];
|
||||
if (variants.length < 2) return "";
|
||||
const active = activeVariant(p);
|
||||
return `<div class="shade-picker" data-shade-picker>
|
||||
<p class="shade-picker-label">رنگ: <span data-shade-label>${escapeHtml(active?.shade || "")}</span></p>
|
||||
<div class="shade-swatches" role="listbox" aria-label="انتخاب رنگ">
|
||||
${variants
|
||||
.map((v) => {
|
||||
const isActive = v.id === active?.id;
|
||||
const bg = v.heroImage
|
||||
? `background-image:url('${escapeHtml(v.heroImage)}')`
|
||||
: "";
|
||||
const variantTitle = displayTitle({ ...p, slug: v.slug, name: v.name || p.name });
|
||||
const imgs = productGalleryImages(p, v);
|
||||
const m = moneyItem(p, v);
|
||||
const sell = sellPriceFromItem(m);
|
||||
const sellCompare = sellCompareFromItem(m);
|
||||
return `<button
|
||||
type="button"
|
||||
class="shade-swatch${isActive ? " is-active" : ""}"
|
||||
data-shade-select
|
||||
data-id="${escapeHtml(v.id || p.id)}"
|
||||
data-slug="${escapeHtml(v.slug)}"
|
||||
data-name="${escapeHtml(variantTitle)}"
|
||||
data-shade="${escapeHtml(v.shade || "")}"
|
||||
data-intro="${escapeHtml(v.description || v.descriptionIntro || p.description || p.descriptionIntro || "")}"
|
||||
data-price="${sell}"
|
||||
data-compare="${sellCompare ?? ""}"
|
||||
data-image="${escapeHtml(v.heroImage || "")}"
|
||||
data-images="${escapeHtml(JSON.stringify(imgs))}"
|
||||
data-stock="${v.stock ?? 0}"
|
||||
role="option"
|
||||
aria-selected="${isActive ? "true" : "false"}"
|
||||
title="${escapeHtml(v.shade || "")}"
|
||||
${isActive ? 'aria-current="true"' : ""}
|
||||
><span class="shade-swatch-img" style="${bg}"></span><span class="shade-swatch-code">${escapeHtml(v.shade || "")}</span></button>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Main image for buy column; remaining shots go in the content gallery. */
|
||||
function splitProductImages(p, v) {
|
||||
const images = productGalleryImages(p, v);
|
||||
const main = v?.heroImage || p.heroImage || images[0] || "";
|
||||
const rest = [];
|
||||
let skippedMain = false;
|
||||
for (const src of images) {
|
||||
if (!skippedMain && main && src === main) {
|
||||
skippedMain = true;
|
||||
continue;
|
||||
}
|
||||
rest.push(src);
|
||||
}
|
||||
return { main, rest, images };
|
||||
}
|
||||
|
||||
function buyPanelHtml(p) {
|
||||
const title = displayTitle(p);
|
||||
const v = activeVariant(p);
|
||||
const money = moneyItem(p, v);
|
||||
const price = sellPriceFromItem(money);
|
||||
const compare = sellCompareFromItem(money);
|
||||
const discount =
|
||||
compare && compare > price
|
||||
? Math.round(((compare - price) / compare) * 100)
|
||||
: null;
|
||||
const stock = v?.stock ?? p.stock ?? 0;
|
||||
const low = p.lowStockThreshold ?? 5;
|
||||
const { main: hero } = splitProductImages(p, v);
|
||||
const stocked = Number(stock) > 0;
|
||||
const heroAlt =
|
||||
(v && (v.heroImageAlt || v.nameFa || v.name)) ||
|
||||
p.heroImageAlt ||
|
||||
title ||
|
||||
"";
|
||||
|
||||
let stockMsg = "";
|
||||
if (!stocked) stockMsg = "ناموجود";
|
||||
else if (stock <= low) stockMsg = `موجودی محدود ${stock}`;
|
||||
|
||||
const heroBlock = hero
|
||||
? `<figure class="buy-hero" data-buy-hero>
|
||||
<img src="${escapeHtml(hero)}" alt="${escapeHtml(heroAlt)}" data-buy-hero-img loading="eager" />
|
||||
</figure>`
|
||||
: `<figure class="buy-hero" data-buy-hero hidden>
|
||||
<img src="" alt="" data-buy-hero-img />
|
||||
</figure>`;
|
||||
|
||||
return `<div class="buy-panel" id="buy" data-buy-panel data-category="${escapeHtml(p.category || "")}" data-category-fa="${escapeHtml(p.categoryFa || "")}" data-low-stock="${low}" data-label-add="افزودن به سبد" data-label-oos="ناموجود" data-label-limited="موجودی محدود" data-label-sold="ناموجود">
|
||||
<p class="label-caps text-muted">انحصاری</p>
|
||||
<h1 data-buy-title>${escapeHtml(title)}</h1>
|
||||
${p.tagline ? `<p class="tagline">${escapeHtml(p.tagline)}</p>` : ""}
|
||||
<div class="price-row" data-buy-price>
|
||||
<span class="now">${escapeHtml(formatPrice(price))}</span>
|
||||
${
|
||||
discount != null
|
||||
? `<span class="was">${escapeHtml(formatPrice(compare))}</span><span class="offer-badge offer-badge--buy" data-buy-discount>−${discount}%</span>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<p class="buy-cta-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-block"
|
||||
data-add-to-cart
|
||||
data-product-id="${escapeHtml(v?.id || p.id)}"
|
||||
data-slug="${escapeHtml(v?.slug || p.slug)}"
|
||||
data-name="${escapeHtml(title)}"
|
||||
data-price="${price}"
|
||||
data-image="${escapeHtml(hero)}"
|
||||
data-category="${escapeHtml(p.category || "")}"
|
||||
data-category-fa="${escapeHtml(p.categoryFa || "")}"
|
||||
${stocked ? "" : "disabled"}
|
||||
>${stocked ? "افزودن به سبد" : "ناموجود"}</button>
|
||||
<button type="button" class="btn btn-block btn-share" data-share-product data-share-title="${escapeHtml(title)}" aria-label="اشتراکگذاری">اشتراکگذاری</button>
|
||||
</p>
|
||||
<p class="text-muted buy-stock-msg" data-buy-stock style="font-size:0.875rem">${escapeHtml(stockMsg)}</p>
|
||||
${heroBlock}
|
||||
${shadePickerHtml(p)}
|
||||
<ul class="buy-perks"><li>ضمانت اصالت کالا</li></ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function galleryImageAlt(src, index, richAlts, fallbackAlt) {
|
||||
const fromRich =
|
||||
richAlts.find((x) => x && x.url === src)?.alt ||
|
||||
(typeof richAlts[index] === "string" ? richAlts[index] : richAlts[index]?.alt);
|
||||
return fromRich || (index === 0 ? fallbackAlt : `${fallbackAlt} – تصویر ${index + 1}`);
|
||||
}
|
||||
|
||||
/** Extra photos (hero lives under the buy CTAs). Stacked full-width. */
|
||||
function galleryHtml(p) {
|
||||
const v = activeVariant(p);
|
||||
const { rest: images } = splitProductImages(p, v);
|
||||
if (!images.length) return "";
|
||||
const richAlts = (v && v.imageAlts) || p.imageAlts || [];
|
||||
const fallbackAlt =
|
||||
(v && (v.heroImageAlt || v.nameFa || v.name)) ||
|
||||
p.heroImageAlt ||
|
||||
p.nameFa ||
|
||||
p.name ||
|
||||
"";
|
||||
return images
|
||||
.map((src, i) => {
|
||||
const alt = galleryImageAlt(src, i + 1, richAlts, fallbackAlt);
|
||||
return `<figure class="pdp-gallery-shot">
|
||||
<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" loading="${i === 0 ? "eager" : "lazy"}" />
|
||||
</figure>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function productSlugFromPath() {
|
||||
const m = location.pathname.match(/\/products\/([^/]+)\/?$/);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared PDP for every catalog product (and any future import/export).
|
||||
* Routing always serves products/index.html; this hydrates from catalog.dedupe2.json by slug.
|
||||
*/
|
||||
export async function initProductPage() {
|
||||
if (!document.body.hasAttribute("data-product-page")) return;
|
||||
const slug = productSlugFromPath();
|
||||
const root = document.getElementById("product-root");
|
||||
const heroWrap = document.querySelector("[data-pdp-hero-wrap]");
|
||||
const gallery = document.querySelector("[data-variant-gallery]");
|
||||
const desc = document.querySelector("[data-pdp-description]");
|
||||
if (!slug || !root) return;
|
||||
|
||||
await ensureEurRate();
|
||||
|
||||
let products = [];
|
||||
try {
|
||||
const res = await fetch("/data/catalog.dedupe2.json");
|
||||
if (!res.ok) throw new Error("catalog");
|
||||
const data = await res.json();
|
||||
products = data.products || [];
|
||||
} catch {
|
||||
root.innerHTML = `<p class="text-muted">بارگذاری محصول ناموفق بود.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const p = products.find((x) => x.slug === slug || x.colorVariants?.some(v=>v.slug===slug));
|
||||
if (!p) {
|
||||
root.innerHTML = `<p class="text-muted">محصول یافت نشد.</p><p><a href="/collection/">بازگشت به فروشگاه</a></p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const title = displayTitle(p);
|
||||
document.title = `${title} | کیکومیکو`;
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
if (metaDesc) {
|
||||
metaDesc.setAttribute(
|
||||
"content",
|
||||
p.seoDescription || p.tagline || p.description || document.title
|
||||
);
|
||||
}
|
||||
|
||||
if (gallery) {
|
||||
gallery.innerHTML = galleryHtml(p);
|
||||
if (heroWrap) heroWrap.hidden = !gallery.innerHTML;
|
||||
}
|
||||
|
||||
const shadesSlot = document.querySelector("[data-pdp-shades]");
|
||||
if (shadesSlot) {
|
||||
shadesSlot.innerHTML = "";
|
||||
shadesSlot.hidden = true;
|
||||
}
|
||||
|
||||
root.innerHTML = buyPanelHtml(p);
|
||||
initShadePicker();
|
||||
|
||||
renderProductContent(desc, p, activeVariant(p) || {});
|
||||
root.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-shade-select]");
|
||||
if (!button) return;
|
||||
const variant = p.colorVariants?.find((v) => v.id === button.dataset.id);
|
||||
if (variant) queueMicrotask(() => renderProductContent(desc, p, variant));
|
||||
});
|
||||
}
|
||||
Vendored
+192
@@ -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 });
|
||||
});
|
||||
}
|
||||
Vendored
+156
@@ -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();
|
||||
}
|
||||
Vendored
+22
@@ -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));
|
||||
}
|
||||
Vendored
+56
@@ -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();
|
||||
}
|
||||
Vendored
+53
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
Vendored
+167
@@ -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 = "بارگذاری نتایج ممکن نشد.");
|
||||
}
|
||||
}
|
||||
Vendored
+149
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user