Files
Kikomiko/kikomiko/template-reference/build-static.ts
T

1409 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Build a fully static storefront into dist/ for Cloudflare Workers.
* Reads data/site.json only — no database required on CI.
*/
import fs from "node:fs";
import { assertPersianProductCopy } from "../src/lib/sync/persian-translator";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getDictionary } from "../src/lib/i18n/dictionaries";
import {
KIKO_CATEGORY_ROOTS,
slugifySegment,
parseKikoPath,
translateKikoSegment,
commercialProductSearchTerm,
commercialProductType,
} from "../src/lib/categories/kiko";
import { formatPrice, parseJsonObject } from "../src/lib/utils";
import { rankRelatedProducts } from "../src/lib/related-products";
import {
ROBOTS_TXT,
aboutPageJsonLd,
authenticityPageJsonLd,
breadcrumbJsonLd,
contactPageJsonLd,
buildSitemapXml,
faqJsonLd,
jsonLdGraph,
listingJsonLd,
organizationJsonLd,
productBreadcrumbItems,
productJsonLd,
seoAboutDescription,
seoAboutTitle,
seoAuthenticityDescription,
seoAuthenticityTitle,
seoContactDescription,
seoContactTitle,
seoCategoryDescription,
seoCategoryIntro,
seoHomeDescription,
seoHomeTitle,
seoListingTitle,
seoOffersDescription,
seoProductDescription,
seoProductTitle,
websiteJsonLd,
} from "../src/lib/seo";
import {
buyPanel,
atelierHeroHtml,
bestsellersSection,
beautyInspirationBanner,
collabBannersSection,
faqSection,
layout,
listProductCard,
aboutPageBody,
authenticityPageBody,
contactPageBody,
offerProductCard,
offersSliderSection,
promoBanner,
promoHeroSliderHtml,
reviewsSection,
topCategoriesSection,
kikoShopByCategoryItems,
trustSection,
whyUsSection,
productGridCard,
productListingFilters,
relatedProductCard,
renderBlocks,
pdpGallerySection,
} from "../vanilla/lib/html";
import type { HeroContent } from "../src/lib/blocks/types";
import type { MegaMenuRoot } from "../src/lib/mega-menu";
import {
attachColorVariants,
buildColorVariantMap,
dedupeByColorFamily,
displayNameWithoutShade,
splitProductName,
type ColorVariant,
} from "../src/lib/color-variants";
import {
normalizeProductCategory,
persianCategoryLabel,
withPersianCategory,
} from "../src/lib/categories/kiko";
import { productFacts } from "../src/lib/product-facts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const DIST = path.join(ROOT, "dist");
const SITE_JSON = path.join(ROOT, "data", "site.json");
type SiteProduct = {
id: string;
slug: string;
name: string;
nameFa?: string | null;
tagline: string | null;
description: string | null;
seoDescription?: string | null;
price: number;
compareAtPrice: number | null;
heroImage: string | null;
images: string[];
category: string | null;
stock: number;
lowStockThreshold: number;
featured: boolean;
updatedAt: string;
variantGroupId?: string | null;
shadeCode?: string | null;
variantCanonical?: boolean;
related: Array<{
id: string;
slug: string;
name: string;
nameFa?: string | null;
price: number;
heroImage: string | null;
category: string | null;
tagline?: string | null;
colorVariants?: ColorVariant[];
}>;
blocks: Array<{ type: string; content: string }>;
ratingAvg: number;
reviewCount: number;
colorVariants?: ColorVariant[];
};
type SiteData = {
generatedAt: string;
eurToTomanRate?: number;
eurToTomanSource?: string;
megaMenu: MegaMenuRoot[];
products: SiteProduct[];
};
function esc(s: unknown) {
return String(s ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function write(rel: string, content: string | Buffer) {
const full = path.join(DIST, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function redirectHtml(target: string, title = "Kikomiko") {
return `<!DOCTYPE html><html lang="fa"><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=${esc(target)}"/><link rel="canonical" href="${esc(target)}"/><link rel="icon" href="/favicon.svg" type="image/svg+xml"/><title>${esc(title)}</title></head><body><p><a href="${esc(target)}">ادامه به فروشگاه</a></p></body></html>`;
}
function productFactsSection(product: SiteProduct) {
const facts = productFacts(product);
return `<section class="block-section container-luxury" aria-labelledby="product-facts-title">
<p class="label-caps text-muted">اطلاعات رسمی محصول</p>
<h2 id="product-facts-title">مشخصات و نحوه استفاده</h2>
<dl class="product-facts" lang="fa" dir="rtl">
${facts.map((fact) => `<div><dt><strong>${esc(fact.label)}</strong></dt><dd${fact.label === "شید" ? ' data-product-fact="shade"' : ""}>${esc(fact.value)}</dd></div>`).join("")}
</dl>
</section>`;
}
function productDescriptionHtml(description: string) {
const [intro, ...rest] = 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>`;
}
function writeLegacyFaRedirect(rel: string, target: string, title?: string) {
write(`fa/${rel}`, redirectHtml(target, title));
}
function copyDir(src: string, dest: string) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(from, to);
else fs.copyFileSync(from, to);
}
}
function rewriteMegaForStatic(mega: MegaMenuRoot[]): MegaMenuRoot[] {
return mega.map((root) => {
const base = root.href.replace(/^\/fa(?=\/|$)/, "").replace(/\?.*$/, "") || "/";
return {
...root,
href: base.endsWith("/") ? base : `${base}/`,
children: root.children.map((c) => {
const sub = new URL(c.href, "http://local").searchParams.get("sub") || c.key;
const slug = slugifySegment(sub) || c.key;
return {
...c,
label: translateKikoSegment(c.label),
href: `${base.replace(/\/$/, "")}/sub/${slug}/`,
};
}),
};
});
}
function matchesRoot(category: string | null, lev1: string) {
const parts = parseKikoPath(category);
return parts[0]?.toUpperCase() === lev1.toUpperCase();
}
function main() {
console.log("Building static site → dist/ (from data/site.json)");
if (!fs.existsSync(SITE_JSON)) {
throw new Error(
"Missing data/site.json. Run locally: npm run export:catalog"
);
}
const site = JSON.parse(fs.readFileSync(SITE_JSON, "utf8")) as SiteData;
const dict = getDictionary("fa");
const megaMenu = rewriteMegaForStatic(site.megaMenu || []);
const products = (site.products || []).map((p) => ({
...p,
category: normalizeProductCategory(p.name, p.category) || p.category,
}));
products.forEach(assertPersianProductCopy);
const catalogUpdatedAt = site.generatedAt;
const eurToTomanRate =
typeof site.eurToTomanRate === "number" &&
Number.isFinite(site.eurToTomanRate) &&
site.eurToTomanRate > 0
? site.eurToTomanRate
: undefined;
const sitemapPaths: string[] = [];
function pageLayout(opts: Parameters<typeof layout>[0]) {
return layout({ catalogUpdatedAt, eurToTomanRate, ...opts });
}
fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(DIST, { recursive: true });
const listingSource = products.filter((p) => p.variantCanonical !== false);
const variantMapAll = buildColorVariantMap(
products.map((p) => ({
id: p.id,
slug: p.slug,
name: p.name,
category: p.category,
heroImage: p.heroImage,
images: p.images,
stock: p.stock,
price: p.price,
compareAtPrice: p.compareAtPrice,
variantGroupId: p.variantGroupId,
shadeCode: p.shadeCode,
variantCanonical: p.variantCanonical,
}))
);
const listingProducts = attachColorVariants(
dedupeByColorFamily(listingSource),
variantMapAll
).map(
(p) => {
if (p.colorVariants && p.colorVariants.length > 1) return p;
const fromExport = products.find((x) => x.id === p.id)?.colorVariants;
return fromExport && fromExport.length > 1
? { ...p, colorVariants: fromExport }
: p;
}
);
const catalog = {
generatedAt: site.generatedAt,
version: "dedupe2",
products: listingProducts.map((p) => {
const category = normalizeProductCategory(p.name, p.category);
return {
id: p.id,
slug: p.slug,
name: p.name,
nameFa: p.nameFa ?? null,
price: p.price,
compareAtPrice: p.compareAtPrice ?? null,
stock: p.stock,
heroImage: p.heroImage,
category,
categoryFa: persianCategoryLabel(category, p.name),
featured: p.featured,
updatedAt: p.updatedAt,
ratingAvg: p.ratingAvg,
reviewCount: p.reviewCount,
colorVariants: p.colorVariants,
};
}),
};
write("data/catalog.json", JSON.stringify(catalog));
write("data/catalog.dedupe2.json", JSON.stringify(catalog));
copyDir(path.join(ROOT, "vanilla/public/css"), path.join(DIST, "css"));
copyDir(path.join(ROOT, "vanilla/public/js"), path.join(DIST, "js"));
copyDir(path.join(ROOT, "vanilla/public/images"), path.join(DIST, "images"));
for (const rootAsset of [
"favicon.svg",
"60162903.txt",
"37941746.txt",
"۷۲۶۴۵۵۱۷.txt",
]) {
const src = path.join(ROOT, "vanilla/public", rootAsset);
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(DIST, rootAsset));
}
}
copyDir(path.join(ROOT, "public/fonts"), path.join(DIST, "fonts"));
// Prefer vanilla/public/videos (storefront source of truth); fall back to public/videos
const vanillaVideos = path.join(ROOT, "vanilla/public/videos");
const publicVideos = path.join(ROOT, "public/videos");
copyDir(
fs.existsSync(vanillaVideos) ? vanillaVideos : publicVideos,
path.join(DIST, "videos")
);
const skipUploads = process.env.SKIP_UPLOADS !== "0";
if (!skipUploads) {
copyDir(path.join(ROOT, "public/uploads"), path.join(DIST, "uploads"));
} else {
console.log("SKIP_UPLOADS (default) — not copying product images");
}
const bestsellers = attachColorVariants(
dedupeByColorFamily(listingSource.filter((p) => p.featured)),
variantMapAll
).slice(0, 12);
const offers70 = (() => {
const eligible = products
.filter(
(p) =>
p.compareAtPrice != null &&
p.compareAtPrice > p.price &&
p.price > 0 &&
p.heroImage &&
Math.round((1 - p.price / p.compareAtPrice) * 100) >= 30
)
.map((p) => ({
...p,
discountPercent: Math.round(
(1 - p.price / (p.compareAtPrice as number)) * 100
),
}))
.sort((a, b) => b.discountPercent - a.discountPercent);
const attached = attachColorVariants(
dedupeByColorFamily(eligible),
variantMapAll
);
// Prefer cards that can show shade dots in the first screenful
const withShades = attached.filter(
(p) => (p.colorVariants?.length || 0) > 1
);
const withoutShades = attached.filter(
(p) => (p.colorVariants?.length || 0) <= 1
);
return [...withShades, ...withoutShades].slice(0, 15);
})();
const HOME_COLLECTION_LIMIT = 20;
const homeWithShades = listingProducts.filter(
(p) => (p.colorVariants?.length || 0) > 1
);
const homeWithoutShades = listingProducts.filter(
(p) => (p.colorVariants?.length || 0) <= 1
);
const homeCollection = [
...homeWithShades,
...homeWithoutShades,
].slice(0, HOME_COLLECTION_LIMIT);
const homeBody = `
<div class="home-shell">
<header class="sr-only">
<h1>خرید محصولات اصل KIKO Milano در ایران</h1>
<p>فروشگاه تخصصی محصولات کیکو میلانو، واردات مستقیم از ایتالیا با تضمین اصالت و دسترسی به جدیدترین کالکشن‌ها و شیدها.</p>
</header>
${atelierHeroHtml(
products
.map((p) => p.heroImage)
.filter((src): src is string => Boolean(src))
.slice(0, 10)
)}
${promoHeroSliderHtml()}
${topCategoriesSection(kikoShopByCategoryItems(megaMenu))}
${bestsellersSection(dict.bestsellers, bestsellers, { trailingSlash: true })}
${collabBannersSection({ trailingSlash: true })}
${offersSliderSection(dict.offers70, offers70, { trailingSlash: true })}
${whyUsSection()}
${beautyInspirationBanner()}
${reviewsSection()}
${faqSection()}
${promoBanner({
title: "محصولات",
titleBold: "ویژه و تخفیف‌دار",
sub: "فرصت محدود برای کشف بهترین پیشنهادهای اورجینال کیکو با ضمانت اصالت.",
ctaLabel: "مشاهده پیشنهادها",
ctaHref: "/offers/",
})}
<div class="collection-wrap" id="collection" data-static-collection data-home-limit="${HOME_COLLECTION_LIMIT}">
<p class="collection-label label-caps">${esc(dict.interested)}</p>
<div class="filters">
${productListingFilters({
megaMenu,
productCount: homeCollection.length,
currentSort: "newest",
mode: "home",
hash: "collection",
clientMode: true,
})}
</div>
<div class="collection-title">
<p class="label-caps text-muted">${esc(dict.featured)}</p>
<h2>جدیدترین <strong>محصولات</strong></h2>
</div>
<div class="product-grid" data-product-grid>
${homeCollection.map((p) => productGridCard(p, { trailingSlash: true })).join("")}
</div>
<div class="collection-footer">
<a class="btn collection-see-all" href="/collection/">${esc(dict.seeAllProducts)}</a>
</div>
</div>
${trustSection()}
</div>`;
write(
"index.html",
pageLayout({
title: seoHomeTitle(),
description: seoHomeDescription(),
dict,
megaMenu,
canonicalPath: "/",
ogImage: "/videos/kiko-hero-poster.jpg",
body: homeBody,
headExtra: `${jsonLdGraph([organizationJsonLd(), websiteJsonLd(), faqJsonLd()])}
<link rel="preload" as="image" href="/videos/kiko-hero-poster.jpg?v=3" fetchpriority="high" />`,
})
);
sitemapPaths.push("/");
writeLegacyFaRedirect("index.html", "/", "Kikomiko");
write(
"collection/index.html",
pageLayout({
title: seoListingTitle("همه محصولات"),
description: seoCategoryDescription("همه محصولات"),
dict,
megaMenu,
canonicalPath: "/collection/",
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "همه محصولات", path: "/collection/" },
]),
listingJsonLd({
name: "همه محصولات کیکو میلانو",
description: seoCategoryDescription("همه محصولات"),
urlPath: "/collection/",
items: listingProducts.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: `<div class="page-pad">
<div class="container-luxury" style="padding-bottom:1.5rem">
<p class="label-caps text-muted">${esc(dict.collection)}</p>
<h1 class="page-title">${esc(dict.seeAllProducts)}</h1>
</div>
<div class="container-luxury" style="padding-bottom:2rem">
<div class="filters" style="margin:0">
${productListingFilters({
megaMenu,
productCount: listingProducts.length,
mode: "collection",
clientMode: true,
})}
</div>
</div>
<div class="container-luxury list-grid" data-product-grid>${listingProducts
.map((p) => listProductCard(p, { trailingSlash: true }))
.join("")}</div>
</div>`,
})
);
sitemapPaths.push("/collection/");
writeLegacyFaRedirect("collection/index.html", "/collection/", `${dict.collection} | Kikomiko`);
const offersListing = (() => {
const eligible = listingSource
.filter(
(p) =>
p.compareAtPrice != null &&
p.compareAtPrice > p.price &&
p.price > 0 &&
p.heroImage &&
Math.round((1 - p.price / p.compareAtPrice) * 100) >= 30
)
.map((p) => ({
...p,
discountPercent: Math.round(
(1 - p.price / (p.compareAtPrice as number)) * 100
),
}))
.sort((a, b) => b.discountPercent - a.discountPercent);
return attachColorVariants(dedupeByColorFamily(eligible), variantMapAll);
})();
write(
"offers/index.html",
pageLayout({
title: seoListingTitle("محصولات تخفیف‌دار"),
description: seoOffersDescription(),
dict,
megaMenu,
canonicalPath: "/offers/",
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "با تخفیف", path: "/offers/" },
]),
listingJsonLd({
name: "محصولات تخفیف‌دار کیکو میلانو",
description: seoOffersDescription(),
urlPath: "/offers/",
items: offersListing.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: `<div class="page-pad">
<div class="container-luxury" style="padding-bottom:1.5rem">
<p class="label-caps text-muted">${esc(dict.offers70)}</p>
<h1 class="page-title">${esc(dict.withDiscount)}</h1>
<p class="text-muted" style="margin-top:0.75rem">محصولات با حداقل ۳۰٪ تخفیف — شامل ۳۰٪، ۵۰٪ و ۷۰٪ و بیشتر</p>
</div>
${
offersListing.length === 0
? `<div class="container-luxury section-padding" style="text-align:center"><p class="text-muted">${esc(dict.noProducts)}</p></div>`
: `<div class="container-luxury" style="padding-bottom:1rem">
<div class="filters" style="margin:0">
${productListingFilters({
megaMenu,
productCount: offersListing.length,
mode: "offers",
clientMode: true,
})}
</div>
</div>
<div class="container-luxury list-grid" data-product-grid>${offersListing
.map((p) => offerProductCard(p, { trailingSlash: true }))
.join("")}</div>`
}
</div>`,
})
);
sitemapPaths.push("/offers/");
writeLegacyFaRedirect("offers/index.html", "/offers/", `${dict.withDiscount} | Kikomiko`);
for (const root of KIKO_CATEGORY_ROOTS) {
const inRoot = attachColorVariants(
dedupeByColorFamily(
listingSource.filter((p) => matchesRoot(p.category, root.lev1))
),
variantMapAll
);
write(
`category/${root.slug}/index.html`,
pageLayout({
title: seoListingTitle(root.fa),
description: seoCategoryDescription(root.fa),
dict,
megaMenu,
canonicalPath: `/category/${root.slug}/`,
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: root.fa, path: `/category/${root.slug}/` },
]),
listingJsonLd({
name: root.fa,
description: seoCategoryDescription(root.fa),
urlPath: `/category/${root.slug}/`,
items: inRoot.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: categoryBody(dict, root.fa, root.slug, inRoot, null, megaMenu),
})
);
sitemapPaths.push(`/category/${root.slug}/`);
writeLegacyFaRedirect(
`category/${root.slug}/index.html`,
`/category/${root.slug}/`,
`${root.fa} | Kikomiko`
);
const megaRoot = megaMenu.find((m) => m.href.includes(`/category/${root.slug}`));
if (!megaRoot) continue;
for (const child of megaRoot.children) {
const subSlug = child.href.split("/sub/")[1]?.replace(/\/$/, "") || child.key;
const landing = categoryLandingFor(subSlug);
const filtered = attachColorVariants(
dedupeByColorFamily(
listingSource.filter((p) => {
if (!matchesRoot(p.category, root.lev1)) return false;
const parts = parseKikoPath(p.category);
return parts.some(
(part) => slugifySegment(part) === subSlug || part === child.key
);
})
),
variantMapAll
);
write(
`category/${root.slug}/sub/${subSlug}/index.html`,
pageLayout({
title: landing?.title ?? seoListingTitle(child.label),
description: seoCategoryDescription(child.label),
dict,
megaMenu,
canonicalPath: `/category/${root.slug}/sub/${subSlug}/`,
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: root.fa, path: `/category/${root.slug}/` },
{ name: child.label, path: `/category/${root.slug}/sub/${subSlug}/` },
]),
listingJsonLd({
name: child.label,
description: seoCategoryDescription(child.label),
urlPath: `/category/${root.slug}/sub/${subSlug}/`,
items: filtered.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: categoryBody(
dict,
child.label,
root.slug,
filtered,
child.label,
megaMenu,
subSlug
),
})
);
sitemapPaths.push(`/category/${root.slug}/sub/${subSlug}/`);
writeLegacyFaRedirect(
`category/${root.slug}/sub/${subSlug}/index.html`,
`/category/${root.slug}/sub/${subSlug}/`,
`${child.label} | Kikomiko`
);
}
}
for (const guide of PRODUCT_TYPE_GUIDES) {
const root = KIKO_CATEGORY_ROOTS.find((item) => item.slug === guide.rootSlug);
if (!root) continue;
const guideProducts = attachColorVariants(
dedupeByColorFamily(
listingSource.filter(
(product) => commercialProductType(product.name, product.category)?.typeFa === guide.typeFa
)
),
variantMapAll
);
const path = `/category/${guide.rootSlug}/type/${guide.slug}/`;
write(
`category/${guide.rootSlug}/type/${guide.slug}/index.html`,
pageLayout({
title: guide.title,
description: guide.description,
dict,
megaMenu,
canonicalPath: path,
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: root.fa, path: `/category/${root.slug}/` },
{ name: guide.typeFa, path },
]),
listingJsonLd({
name: guide.h1,
description: guide.description,
urlPath: path,
items: guideProducts.map((product) => ({
name: withPersianCategory(displayNameWithoutShade(product.name, true), product.category),
path: `/products/${product.slug}/`,
})),
}),
]),
body: productTypeGuideBody(dict, guide, guideProducts, megaMenu),
})
);
sitemapPaths.push(path);
}
const maxProducts = process.env.MAX_PRODUCTS
? Number(process.env.MAX_PRODUCTS)
: products.length;
const slice = products.slice(0, maxProducts);
const byGroup = new Map<string, SiteProduct[]>();
for (const p of products) {
if (!p.variantGroupId) continue;
const list = byGroup.get(p.variantGroupId) || [];
list.push(p);
byGroup.set(p.variantGroupId, list);
}
let i = 0;
for (const product of slice) {
i += 1;
if (i % 100 === 0) console.log(` products ${i}/${slice.length}`);
if (product.variantCanonical === false && product.variantGroupId) {
const family = byGroup.get(product.variantGroupId) || [];
const canonical =
family.find((p) => p.variantCanonical !== false) || family[0];
if (canonical && canonical.slug !== product.slug) {
const shade =
product.shadeCode || splitProductName(product.name).shade || "";
const qs = shade ? `?shade=${encodeURIComponent(shade)}` : "";
const target = `/products/${canonical.slug}/${qs}`;
write(
`products/${product.slug}/index.html`,
`<!DOCTYPE html><html lang="fa"><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=${esc(target)}"/><link rel="canonical" href="${esc(target)}"/><title>${esc(displayNameWithoutShade(product.name, true))} | Kikomiko</title></head><body><p><a href="${esc(target)}">مشاهده محصول</a></p></body></html>`
);
writeLegacyFaRedirect(
`products/${product.slug}/index.html`,
target,
`${displayNameWithoutShade(product.name, true)} | Kikomiko`
);
continue;
}
}
const productImages = product.images?.length
? product.images
: product.heroImage
? [product.heroImage]
: [];
const galleryImages = [
...(product.heroImage ? [product.heroImage] : []),
...productImages.filter((src) => src !== product.heroImage),
];
const heroBlock = product.blocks.find((b) => b.type === "HERO");
const hero = heroBlock
? parseJsonObject<HeroContent>(heroBlock.content, {
headline: product.name,
image: product.heroImage || "",
overlay: "dark",
})
: null;
const pdpDiscount =
product.compareAtPrice != null &&
product.compareAtPrice > product.price
? Math.round(
(1 - product.price / product.compareAtPrice) * 100
)
: 0;
const hasGalleryBlock = product.blocks.some((b) => b.type === "GALLERY");
const factsHtml = productFactsSection(product);
const blocksHtml = product.blocks.length
? `${renderBlocks(product.blocks, galleryImages)}${
!hasGalleryBlock && galleryImages.length
? `<section class="block-section container-luxury">${pdpGallerySection(galleryImages, pdpDiscount)}</section>`
: ""
}${
product.description
? `<section class="block-section container-luxury"><h2>معرفی و توضیحات محصول</h2>${productDescriptionHtml(product.description)}</section>`
: ""
}${factsHtml}`
: `<section class="block-section container-luxury">${pdpGallerySection(galleryImages, pdpDiscount)}${
product.description
? `<h2>معرفی و توضیحات محصول</h2>${productDescriptionHtml(product.description)}`
: ""
}</section>${factsHtml}`;
const hasVariants = (product.colorVariants?.length || 0) > 1;
const pdpTitle = withPersianCategory(
displayNameWithoutShade(product.name, hasVariants),
product.category
);
const heroHtml = hero?.image
? `<section class="pdp-hero">
<img src="${esc(hero.image)}" alt="${esc(hero.imageAlt || hero.headline || pdpTitle)}" />
<div class="pdp-hero-overlay"></div>
<div class="pdp-hero-copy">
<div>
<h1>${esc(hero.headline ? withPersianCategory(hero.headline, product.category) : pdpTitle)}</h1>
${hero.subheadline ? `<p>${esc(hero.subheadline)}</p>` : ""}
${
hero.ctaText && hero.ctaLink
? `<p style="margin-top:1.5rem"><a class="btn btn-light" href="${esc(
hero.ctaLink.startsWith("#") || hero.ctaLink.startsWith("/")
? hero.ctaLink
: `#${hero.ctaLink}`
)}">${esc(hero.ctaText)}</a></p>`
: ""
}
</div>
</div>
</section>`
: `<div style="padding-top:5rem"></div>`;
const sticky = `<div class="sticky-buy">
<div style="min-width:0">
<p style="margin:0;font-size:0.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" data-sticky-name>${esc(pdpTitle)}</p>
<p style="margin:0.2rem 0 0" data-sticky-price>${esc(formatPrice(product.price))}</p>
</div>
<button
type="button"
class="btn"
data-add-to-cart
data-product-id="${esc(product.id)}"
data-slug="${esc(product.slug)}"
data-name="${esc(pdpTitle)}"
data-price="${product.price}"
data-image="${esc(product.heroImage || "")}"
data-category="${esc(product.category || "")}"
data-category-fa="${esc(persianCategoryLabel(product.category, product.name) || "")}"
data-locale="fa"
${product.stock > 0 ? "" : "disabled"}
>${product.stock > 0 ? esc(dict.addToCart) : esc(dict.soldOut)}</button>
</div>`;
const relatedProducts = rankRelatedProducts(
product,
listingSource.filter((candidate) => Boolean(candidate.heroImage)),
4
);
const relatedHtml =
!relatedProducts.length
? ""
: `<section class="section-padding">
<div class="container-luxury">
<p class="label-caps text-muted" style="text-align:center">ادامه کاوش</p>
<h2 style="text-align:center;margin:0.75rem 0 2.5rem;font-weight:300">${esc(dict.youMayAlsoLike)}</h2>
<div class="related-grid">
${relatedProducts
.map((p) => {
const colorVariants =
p.colorVariants && p.colorVariants.length > 1
? p.colorVariants
: variantMapAll.get(p.id);
return relatedProductCard(
{
...p,
colorVariants:
colorVariants && colorVariants.length > 1
? colorVariants
: undefined,
},
{ trailingSlash: true }
);
})
.join("")}
</div>
</div>
</section>`;
const body = `
${heroHtml}
<div class="container-luxury pdp-layout">
${buyPanel({
product,
dict,
colorVariants: product.colorVariants,
trailingSlash: true,
})}
<div class="pdp-content">${blocksHtml}</div>
</div>
<section class="section-padding" style="background:rgba(247,247,247,0.6)" id="reviews">
<div class="container-luxury">
<p class="label-caps text-muted">${esc(dict.reviews)}</p>
<h2 style="margin:0.75rem 0 2rem;font-size:clamp(1.75rem,4vw,2.25rem);font-weight:300">نظرات مشتریان</h2>
<p class="text-muted">هنوز نظری ثبت نشده است.</p>
</div>
</section>
${relatedHtml}
${sticky}
`;
write(
`products/${product.slug}/index.html`,
pageLayout({
title: seoProductTitle(pdpTitle),
description: seoProductDescription({
name: pdpTitle,
categoryFa: persianCategoryLabel(product.category, product.name),
commercialTerm: commercialProductSearchTerm(product.name, product.category),
existing: product.seoDescription || product.tagline || product.description,
}),
dict,
megaMenu,
canonicalPath: `/products/${product.slug}/`,
ogImage: product.heroImage,
ogType: "product",
headExtra: jsonLdGraph([
organizationJsonLd(),
breadcrumbJsonLd(
productBreadcrumbItems({
name: pdpTitle,
slug: product.slug,
category: product.category,
})
),
productJsonLd({
name: pdpTitle,
description:
product.seoDescription || product.tagline || product.description,
image: product.heroImage,
extraImages: galleryImages,
urlPath: `/products/${product.slug}/`,
sku: product.slug,
price: product.price,
compareAtPrice: product.compareAtPrice,
inStock:
product.stock > 0 ||
(product.colorVariants || []).some((v) => Number(v.stock) > 0),
}),
]),
body,
})
);
sitemapPaths.push(`/products/${product.slug}/`);
writeLegacyFaRedirect(
`products/${product.slug}/index.html`,
`/products/${product.slug}/`,
`${pdpTitle} | Kikomiko`
);
}
write(
"search/index.html",
pageLayout({
title: `${dict.searchResults} | Kikomiko`,
dict,
megaMenu,
canonicalPath: "/search/",
noindex: true,
body: `<div class="page-pad">
<div class="container-luxury" style="padding-bottom:1.5rem">
<p class="label-caps text-muted">${esc(dict.searchResults)}</p>
<h1 class="page-title" data-search-title>${esc(dict.searchResults)}</h1>
</div>
<div class="container-luxury" style="padding-bottom:2rem">
<div class="filters" style="margin:0">
${productListingFilters({
megaMenu,
productCount: 0,
mode: "search",
clientMode: true,
})}
</div>
</div>
<div class="container-luxury list-grid" data-product-grid data-search-results></div>
<div class="container-luxury section-padding" data-search-empty hidden style="text-align:center">
<p class="text-muted">${esc(dict.searchEmpty)}</p>
<p style="margin-top:1.5rem"><a href="/" class="label-caps" style="text-decoration:underline">${esc(dict.backHome)}</a></p>
</div>
</div>
<script type="module" src="/js/search-page.js?v=filterNav1"></script>`,
})
);
writeLegacyFaRedirect("search/index.html", "/search/", `${dict.searchResults} | Kikomiko`);
write(
"cart/index.html",
pageLayout({
title: `${dict.cart} | Kikomiko`,
dict,
megaMenu,
canonicalPath: "/cart/",
noindex: true,
nofollow: true,
body: `<div class="page-pad container-luxury" data-cart-page>
<p class="label-caps text-muted">${esc(dict.cart)}</p>
<h1 class="page-title">${esc(dict.cart)}</h1>
<div data-cart-empty hidden style="margin-top:2.5rem;text-align:center">
<p class="empty-cart">${esc(dict.emptyCart)}</p>
<p style="margin-top:1.5rem"><a href="/" class="label-caps" style="text-decoration:underline">${esc(dict.continueShopping)}</a></p>
</div>
<div data-cart-content class="cart-page-layout">
<div class="cart-summary cart-summary--page">
<div data-cart-lines></div>
<div class="cart-total"><span>جمع</span><strong data-cart-total>۰ تومان</strong></div>
</div>
<div class="cart-page-actions">
<a class="btn btn-block" href="/checkout/" data-cart-checkout>${esc(dict.proceedToCheckout)}</a>
<p style="margin-top:1rem;text-align:center"><a href="/" class="text-muted" style="text-decoration:underline;font-size:0.875rem">${esc(dict.continueShopping)}</a></p>
</div>
</div>
</div>`,
})
);
writeLegacyFaRedirect("cart/index.html", "/cart/", `${dict.cart} | Kikomiko`);
write(
"checkout/index.html",
pageLayout({
title: `تسویه حساب | Kikomiko`,
dict,
megaMenu,
canonicalPath: "/checkout/",
noindex: true,
nofollow: true,
body: `<div class="page-pad container-luxury">
<p class="label-caps text-muted">تسویه حساب</p>
<h1 class="page-title">اطلاعات سفارش</h1>
<div class="checkout-layout" data-checkout>
<form>
<div class="form-error" data-checkout-error hidden></div>
<div class="form-success" data-checkout-success hidden></div>
<label class="field"><span>نام *</span><input name="firstName" required autocomplete="given-name" /></label>
<label class="field"><span>نام خانوادگی *</span><input name="lastName" required autocomplete="family-name" /></label>
<label class="field"><span>ایمیل</span><input type="email" name="email" autocomplete="email" /></label>
<label class="field"><span>تلفن *</span><input name="phone" type="tel" required placeholder="0912xxxxxxx" autocomplete="tel" /></label>
<label class="field"><span>آدرس *</span><textarea name="address" rows="3" required autocomplete="street-address"></textarea></label>
<div class="checkout-loc-row">
<label class="field"><span>استان *</span>
<select name="province" required data-province-select>
<option value="">انتخاب استان</option>
</select>
</label>
<label class="field"><span>شهر *</span>
<select name="city" required data-city-select disabled>
<option value="">ابتدا استان را انتخاب کنید</option>
</select>
</label>
</div>
<label class="field"><span>یادداشت</span><textarea name="notes" rows="2"></textarea></label>
<button type="submit" class="btn btn-block">ثبت سفارش از طریق واتساپ</button>
<p class="text-muted" style="font-size:0.8rem;margin-top:0.75rem;line-height:1.6">
با ثبت سفارش، فاکتور PDF ساخته می‌شود و واتساپ فروشگاه با لینک فاکتور باز می‌گردد — فقط ارسال را بزنید.
</p>
</form>
<aside>
<div class="cart-summary">
<h2 style="margin-top:0;font-size:1.1rem;font-weight:500">خلاصه سبد</h2>
<div data-cart-lines></div>
<div class="cart-total"><span>جمع</span><strong data-cart-total>۰ تومان</strong></div>
<p style="margin-top:1rem"><a href="/cart/" class="btn btn-block checkout-cart-btn">${esc(dict.cart)}</a></p>
</div>
<div class="cart-summary" data-order-receipt hidden style="margin-top:1.5rem">
<h2 style="margin-top:0;font-size:1.1rem;font-weight:500">رسید سفارش</h2>
<p class="text-muted" style="margin:0.75rem 0 0;font-size:0.875rem">شماره سفارش</p>
<p style="margin:0.25rem 0 0" data-receipt-id></p>
<p class="text-muted" style="margin:0.75rem 0 0;font-size:0.875rem">تاریخ</p>
<p style="margin:0.25rem 0 0" data-receipt-date></p>
<p class="text-muted" style="margin:0.75rem 0 0;font-size:0.875rem">نام مشتری</p>
<p style="margin:0.25rem 0 0" data-receipt-name></p>
<p class="text-muted" style="margin:0.75rem 0 0;font-size:0.875rem">اقلام</p>
<div class="receipt-items" data-receipt-items></div>
<p class="text-muted" style="margin:0.75rem 0 0;font-size:0.875rem">جمع</p>
<p style="margin:0.25rem 0 0" data-receipt-total></p>
<p style="margin-top:1.25rem;display:grid;gap:0.6rem">
<button type="button" class="btn btn-block" data-receipt-whatsapp>ارسال در واتساپ</button>
<button type="button" class="btn btn-block" data-receipt-pdf style="background:#fff;color:#0a0a0a;border:1px solid #0a0a0a">دانلود فاکتور PDF</button>
</p>
</div>
</aside>
</div>
</div>`,
})
);
writeLegacyFaRedirect("checkout/index.html", "/checkout/", `تسویه حساب | Kikomiko`);
write(
"about/index.html",
pageLayout({
title: seoAboutTitle(),
description: seoAboutDescription(),
dict,
megaMenu,
canonicalPath: "/about/",
headExtra: jsonLdGraph([
organizationJsonLd(),
aboutPageJsonLd(),
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "درباره کیکو میکو", path: "/about/" },
]),
]),
body: aboutPageBody(),
})
);
sitemapPaths.push("/about/");
writeLegacyFaRedirect("about/index.html", "/about/", "درباره کیکو میکو | کیکومیکو");
write(
"contact/index.html",
pageLayout({
title: seoContactTitle(),
description: seoContactDescription(),
dict,
megaMenu,
canonicalPath: "/contact/",
headExtra: jsonLdGraph([
organizationJsonLd(),
contactPageJsonLd(),
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "تماس با ما", path: "/contact/" },
]),
]),
body: contactPageBody(),
})
);
sitemapPaths.push("/contact/");
writeLegacyFaRedirect("contact/index.html", "/contact/", "تماس با ما | کیکومیکو");
write(
"authenticity/index.html",
pageLayout({
title: seoAuthenticityTitle(),
description: seoAuthenticityDescription(),
dict,
megaMenu,
canonicalPath: "/authenticity/",
headExtra: jsonLdGraph([
organizationJsonLd(),
authenticityPageJsonLd(),
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "اصالت کالا", path: "/authenticity/" },
]),
]),
body: authenticityPageBody(),
})
);
sitemapPaths.push("/authenticity/");
writeLegacyFaRedirect(
"authenticity/index.html",
"/authenticity/",
"اصالت کالا | کیکومیکو"
);
write(
"404.html",
pageLayout({
title: "یافت نشد | Kikomiko",
dict,
megaMenu,
canonicalPath: "/404.html",
noindex: true,
body: `<div class="container-luxury section-padding" style="text-align:center">
<h1 class="page-title">صفحه پیدا نشد</h1>
<p style="margin-top:1.5rem"><a href="/" class="label-caps" style="text-decoration:underline">${esc(dict.backHome)}</a></p>
</div>`,
})
);
write("robots.txt", ROBOTS_TXT);
write("sitemap.xml", buildSitemapXml(sitemapPaths, catalogUpdatedAt));
console.log(`Done. ${slice.length} product pages in ${DIST}`);
}
function categoryBody(
dict: ReturnType<typeof getDictionary>,
title: string,
rootSlug: string,
products: SiteProduct[],
sub: string | null,
megaMenu: MegaMenuRoot[],
landingKey?: string
) {
const landing = categoryLandingFor(landingKey);
const introLabel = sub || title;
const pageTitle = landing?.h1 ?? title;
const featured = (products.some((p) => p.featured)
? products.filter((p) => p.featured)
: products
).slice(0, 4);
const landingHtml = landing
? `<section class="container-luxury section-padding${landingKey === "lips-make-up" ? " lips-guide--compact" : ""}" aria-labelledby="${esc(landingKey)}-guide-title">
<p class="label-caps text-muted">راهنمای خرید</p>
<h2 id="${esc(landingKey)}-guide-title" class="page-title" style="font-size:clamp(1.5rem,3vw,2.25rem)">${esc(landing.guideTitle)}</h2>
<p class="text-muted" style="margin-top:0.9rem;max-width:48rem;line-height:1.9">${esc(landing.intro)}</p>
<h3 style="margin-top:2rem">دسته‌بندی محصولات</h3>
<ul class="footer-links" style="padding-bottom:0;grid-template-columns:repeat(auto-fit,minmax(10rem,1fr));margin-top:0.85rem">
${landing.types.map((item) => `<li><a href="/search/?q=${encodeURIComponent(item.query)}">${esc(item.label)}</a></li>`).join("")}
</ul>
<h3 style="margin-top:2rem">محصولات پرفروش</h3>
<ul class="footer-links" style="padding-bottom:0;margin-top:0.85rem">
${featured.map((p) => `<li><a href="/products/${esc(p.slug)}/">${esc(displayNameWithoutShade(p.name, Boolean(p.variantGroupId)))}</a></li>`).join("")}
</ul>
<h3 style="margin-top:2rem">راهنمای انتخاب رنگ و فینیش</h3>
<p class="text-muted" style="margin-top:0.75rem;max-width:48rem;line-height:1.9">${esc(landing.guide)}</p>
${landingKey === "lips-make-up" ? `<div style="margin-top:2rem;max-width:48rem"><p class="text-muted" style="line-height:1.8">${esc(seoCategoryIntro(introLabel))}</p><p style="margin-top:1.25rem"><a class="btn" href="/category/${esc(rootSlug)}/">همهٔ دسته‌ها</a></p></div>` : ""}
</section>`
: "";
return `<div class="page-pad${landingKey === "lips-make-up" ? " page-pad--lips-guide" : ""}">
<div class="container-luxury" style="padding-bottom:${landingKey === "lips-make-up" ? "0" : "1.5rem"}">
${landingKey === "lips-make-up" ? "" : `<p class="label-caps text-muted">${esc(dict.collection)}</p>`}
${landingKey === "lips-make-up" ? "" : `<h1 class="page-title">${esc(pageTitle)}</h1>`}
${landingKey === "lips-make-up" ? "" : `<p class="text-muted" style="margin-top:0.75rem;max-width:40rem;line-height:1.8">${esc(seoCategoryIntro(introLabel))}</p>`}
${
sub && landingKey !== "lips-make-up"
? `<p style="margin-top:1.25rem"><a class="btn" href="/category/${esc(rootSlug)}/">همهٔ دسته‌ها</a></p>`
: ""
}
</div>
${landingKey === "lips-make-up" ? landingHtml : ""}
<div class="container-luxury" style="padding-bottom:2rem">
<div class="filters" style="margin:0">
${productListingFilters({
megaMenu,
productCount: products.length,
categorySlug: rootSlug,
currentSub: sub,
mode: "category",
clientMode: true,
})}
</div>
</div>
${
products.length === 0
? `<div class="container-luxury section-padding" style="text-align:center"><p class="text-muted">${esc(dict.noProducts)}</p></div>`
: `<div class="container-luxury list-grid" data-product-grid>${products
.map((p) => listProductCard(p, { trailingSlash: true }))
.join("")}</div>`
}
${landingKey === "lips-make-up" ? "" : landingHtml}
</div>`;
}
type ProductTypeGuide = {
slug: string;
rootSlug: "make-up";
typeFa: string;
h1: string;
title: string;
description: string;
intro: string;
needs: Array<{ title: string; text: string; query: string }>;
comparison: Array<{ title: string; text: string }>;
colour: string;
related: Array<{ label: string; href: string }>;
faq: Array<{ q: string; a: string }>;
};
const PRODUCT_TYPE_GUIDES: ProductTypeGuide[] = [
{
slug: "lip-gloss", rootSlug: "make-up", typeFa: "لیپ گلاس", h1: "خرید لیپ گلاس کیکو میلانو",
title: "خرید لیپ گلاس کیکو میلانو اصل | کیکومیکو", description: "خرید لیپ گلاس کیکو میلانو اصل با رنگ‌ها و فینیش‌های متنوع در کیکومیکو.",
intro: "لیپ گلاس‌های اصل KIKO Milano را در رنگ‌ها و فینیش‌های مختلف ببینید؛ از برق ملایم روزانه تا رنگ‌دهی چشمگیرتر.",
needs: [{ title: "براق‌ترین گزینه‌ها", text: "برای جلوه‌ای درخشان و لب‌های پرتر.", query: "lip gloss" }, { title: "روزانه", text: "بافت سبک و رنگ طبیعی برای هر روز.", query: "lip oil" }, { title: "رنگ‌دهی بیشتر", text: "برای رنگ واضح‌تر و جلوهٔ برجسته‌تر.", query: "pigmented lip gloss" }],
comparison: [{ title: "Glossy", text: "برای ظاهر درخشان و حجیم‌تر." }, { title: "Matte", text: "برای رنگ پررنگ‌تر و بدون درخشش." }],
colour: "پوست روشن: Nude یا Pink؛ پوست متوسط: Rose یا Mauve؛ تناژ گرم: Peach یا Brown. رنگ نهایی را با توجه به فینیش و شدت رنگ انتخاب کنید.",
related: [{ label: "رژ لب کیکو", href: "/category/make-up/sub/lips-make-up/" }, { label: "مداد لب کیکو", href: "/category/make-up/sub/lips-make-up/" }],
faq: [{ q: "برای استفاده روزانه کدام لیپ گلاس مناسب‌تر است؟", a: "بافت‌های سبک با رنگ طبیعی معمولاً برای استفادهٔ روزانه انتخاب راحت‌تری هستند." }, { q: "لیپ گلاس مات است یا براق؟", a: "لیپ گلاس معمولاً جلوه‌ای براق دارد؛ برای نتیجهٔ بدون درخشش، محصولات مات لب را بررسی کنید." }],
},
{
slug: "mascara", rootSlug: "make-up", typeFa: "ریمل", h1: "خرید ریمل کیکو میلانو",
title: "خرید ریمل کیکو میلانو اصل | کیکومیکو", description: "خرید ریمل کیکو میلانو اصل برای حجم، طول و فرم‌دهی مژه‌ها.",
intro: "ریمل‌های KIKO Milano را برای حجم‌دهی، بلندکنندگی یا تفکیک مژه‌ها مقایسه کنید.",
needs: [{ title: "حجم‌دهی", text: "برای مژه‌های پرتر و چشمگیرتر.", query: "volumizing mascara" }, { title: "بلندکننده", text: "برای کشیده‌تر دیده‌شدن مژه‌ها.", query: "lengthening mascara" }, { title: "ماندگار", text: "برای استفادهٔ طولانی یا روزهای شلوغ.", query: "waterproof mascara" }],
comparison: [{ title: "Volume", text: "برای تراکم و جلوهٔ پرتر." }, { title: "Length", text: "برای ظاهر بلندتر و تفکیک‌شده." }],
colour: "مشکی برای تعریف کلاسیک چشم مناسب است؛ برای ظاهر نرم‌تر می‌توانید سراغ قهوه‌ای یا رنگ‌های کالکشن‌های محدود بروید.",
related: [{ label: "خط چشم کیکو", href: "/category/make-up/sub/eyes-make-up/" }, { label: "مداد چشم کیکو", href: "/category/make-up/type/eye-pencil/" }],
faq: [{ q: "ریمل حجم‌دهنده برای چه کسی مناسب است؟", a: "برای کسانی که جلوهٔ پرتر و مژه‌های متراکم‌تر می‌خواهند." }, { q: "ریمل ضدآب چه زمانی بهتر است؟", a: "برای ماندگاری بیشتر در شرایط مرطوب یا استفادهٔ طولانی." }],
},
{
slug: "foundation", rootSlug: "make-up", typeFa: "کرم پودر", h1: "خرید کرم پودر کیکو میلانو",
title: "خرید کرم پودر کیکو میلانو اصل | کیکومیکو", description: "خرید کرم پودر کیکو میلانو اصل با پوشش و فینیش‌های مختلف.",
intro: "کرم پودرهای KIKO Milano را بر اساس پوشش، فینیش و تناژ رنگ پوست انتخاب کنید.",
needs: [{ title: "پوشش طبیعی", text: "برای یکدست‌شدن سبک پوست.", query: "skin tint foundation" }, { title: "پوشش بیشتر", text: "برای نتیجهٔ کامل‌تر.", query: "full coverage foundation" }, { title: "ماندگار", text: "برای آرایش طولانی‌مدت.", query: "long wear foundation" }],
comparison: [{ title: "Natural / Glow", text: "برای پوست تازه و درخشان." }, { title: "Matte", text: "برای جلوهٔ مات‌تر و کنترل برق پوست." }],
colour: "تناژ را با زیرتون پوست هماهنگ کنید: Rose برای زیرتون سرد، Gold برای گرم و Neutral برای حالت متعادل.",
related: [{ label: "کانسیلر کیکو", href: "/category/make-up/type/concealer/" }, { label: "پرایمر کیکو", href: "/category/make-up/sub/face-make-up/" }],
faq: [{ q: "فینیش مات یا درخشان؟", a: "مات برای ظاهر بدون برق و درخشان برای نتیجهٔ طبیعی‌تر و شاداب‌تر مناسب است." }, { q: "چطور رنگ مناسب را انتخاب کنم؟", a: "شماره و زیرتون محصول را با رنگ گردن و زیرتون پوست مقایسه کنید." }],
},
{
slug: "concealer", rootSlug: "make-up", typeFa: "کانسیلر", h1: "خرید کانسیلر کیکو میلانو",
title: "خرید کانسیلر کیکو میلانو اصل | کیکومیکو", description: "خرید کانسیلر کیکو میلانو اصل برای پوشاندن تیرگی و یکدست‌کردن پوست.",
intro: "کانسیلرهای KIKO Milano را برای پوشاندن تیرگی زیر چشم، لک‌ها و روشن‌ترکردن نقاط صورت بررسی کنید.",
needs: [{ title: "زیر چشم", text: "برای ظاهر یکنواخت‌تر زیر چشم.", query: "under eye concealer" }, { title: "پوشش بیشتر", text: "برای پوشاندن لک و ناهماهنگی رنگ.", query: "full coverage concealer" }, { title: "روشن‌کننده", text: "برای نقاط نور صورت.", query: "highlighting concealer" }],
comparison: [{ title: "Lightweight", text: "برای استفادهٔ روزانه و بافت سبک." }, { title: "Full coverage", text: "برای پوشش بیشتر و نتیجهٔ واضح‌تر." }],
colour: "برای زیر چشم، رنگی نزدیک به پوست یا کمی روشن‌تر انتخاب کنید؛ برای لک، نزدیک‌ترین رنگ به کرم پودر معمولاً طبیعی‌تر دیده می‌شود.",
related: [{ label: "کرم پودر کیکو", href: "/category/make-up/type/foundation/" }, { label: "هایلایتر کیکو", href: "/category/make-up/sub/face-make-up/" }],
faq: [{ q: "کانسیلر روشن‌تر یا هم‌رنگ پوست؟", a: "برای زیر چشم کمی روشن‌تر و برای پوشاندن لک، نزدیک به رنگ پوست انتخاب می‌شود." }, { q: "چطور از جمع‌شدن کانسیلر جلوگیری کنم؟", a: "با مقدار کم شروع کنید و آن را با ضربه‌های سبک محو کنید." }],
},
{
slug: "blush", rootSlug: "make-up", typeFa: "رژگونه", h1: "خرید رژگونه کیکو میلانو",
title: "خرید رژگونه کیکو میلانو اصل | کیکومیکو", description: "خرید رژگونه کیکو میلانو اصل با رنگ‌ها و فینیش‌های متنوع.",
intro: "رژگونه‌های KIKO Milano برای رنگ طبیعی روزانه یا جلوهٔ پررنگ‌تر مهمانی در دسترس‌اند.",
needs: [{ title: "ظاهر طبیعی", text: "برای رنگ ملایم و قابل‌ساخت.", query: "natural blush" }, { title: "درخشان", text: "برای جلوهٔ شاداب‌تر پوست.", query: "glow blush" }, { title: "مات", text: "برای رنگ واضح و بدون برق.", query: "matte blush" }],
comparison: [{ title: "Glow", text: "برای پوست شاداب و درخشان." }, { title: "Matte", text: "برای رنگی نرم و بدون درخشش." }],
colour: "Pink و Rose معمولاً برای پوست‌های روشن‌تر، و Peach یا Berry برای طیف‌های گرم‌تر و متوسط انتخاب‌های محبوبی هستند.",
related: [{ label: "برنزر کیکو", href: "/category/make-up/sub/face-make-up/" }, { label: "هایلایتر کیکو", href: "/category/make-up/sub/face-make-up/" }],
faq: [{ q: "رژگونه مات یا درخشان؟", a: "مات ظاهر نرم‌تری می‌دهد و درخشان، نور و شادابی بیشتری به گونه‌ها اضافه می‌کند." }, { q: "رنگ رژگونه را چطور انتخاب کنم؟", a: "به زیرتون پوست و شدت رنگی که در آرایش روزانه می‌پسندید توجه کنید." }],
},
{
slug: "eye-pencil", rootSlug: "make-up", typeFa: "مداد چشم", h1: "خرید مداد چشم کیکو میلانو",
title: "خرید مداد چشم کیکو میلانو اصل | کیکومیکو", description: "خرید مداد چشم کیکو میلانو اصل با رنگ‌ها و فینیش‌های متنوع.",
intro: "مداد چشم‌های KIKO Milano برای خط داخل چشم، خط مژه و ساخت آرایش‌های محو یا دقیق انتخاب‌های متنوعی دارند.",
needs: [{ title: "خط دقیق", text: "برای تعریف خط مژه.", query: "eye pencil" }, { title: "اسموکی", text: "برای محوکردن و عمق بیشتر.", query: "smoky eye pencil" }, { title: "رنگی", text: "برای یک نقطهٔ رنگی در آرایش چشم.", query: "colour eye pencil" }],
comparison: [{ title: "Matte", text: "برای خطی واضح و کنترل‌شده." }, { title: "Metallic", text: "برای جلوه‌ای نورانی‌تر روی چشم." }],
colour: "Black و Brown برای استفادهٔ روزانه و تعریف چشم مناسب‌اند؛ رنگ‌های آبی، سبز یا بنفش برای آرایش‌های رنگی انتخاب جذابی هستند.",
related: [{ label: "خط چشم کیکو", href: "/category/make-up/sub/eyes-make-up/" }, { label: "ریمل کیکو", href: "/category/make-up/type/mascara/" }],
faq: [{ q: "مداد چشم برای خط داخل چشم مناسب است؟", a: "بسته به مشخصات هر محصول، برخی مدادها برای خط داخلی و خارجی چشم طراحی شده‌اند." }, { q: "فینیش مات یا متالیک؟", a: "مات برای آرایش کلاسیک و متالیک برای جلوهٔ نورانی‌تر مناسب است." }],
},
];
function productTypeGuideBody(
dict: ReturnType<typeof getDictionary>,
guide: ProductTypeGuide,
products: SiteProduct[],
megaMenu: MegaMenuRoot[]
) {
const featured = products.slice(0, 4);
return `<div class="page-pad">
<header class="container-luxury type-guide-header">
<nav class="type-guide-crumb" aria-label="breadcrumb"><a href="/">خانه</a><span>‹</span><a href="/category/${guide.rootSlug}/">آرایش</a><span>‹</span><span>${esc(guide.typeFa)}</span></nav>
<h1 class="page-title">${esc(guide.h1)}</h1>
<p class="text-muted type-guide-summary">${esc(guide.intro)}</p>
</header>
<div class="container-luxury type-guide-filters"><div class="filters" style="margin:0">${productListingFilters({ megaMenu, productCount: products.length, categorySlug: guide.rootSlug, mode: "category", clientMode: true })}</div></div>
<div class="container-luxury list-grid" id="products" data-product-grid>${products.map((product) => listProductCard(product, { trailingSlash: true })).join("")}</div>
<section class="editorial-guide" aria-labelledby="${guide.slug}-guide-title"><div class="container-luxury">
<div class="editorial-guide-intro"><p class="label-caps text-muted">راهنمای خرید</p><h2 id="${guide.slug}-guide-title">راهنمای خرید ${esc(guide.typeFa)} کیکو</h2><p>${esc(guide.intro)}</p></div>
<div class="editorial-need-grid">${guide.needs.map((need, index) => `<a class="editorial-need-card" href="/search/?q=${encodeURIComponent(need.query)}"><span>✦ ${String(index + 1).padStart(2, "۰")}</span><h3>${esc(need.title)}</h3><p>${esc(need.text)}</p><b>مشاهده</b></a>`).join("")}</div>
<div class="editorial-two-col"><section><h3>فینیش مناسب شما کدام است؟</h3><div class="editorial-compare">${guide.comparison.map((item) => `<article><h4>${esc(item.title)}</h4><p>${esc(item.text)}</p></article>`).join("")}</div></section><section><h3>چطور انتخاب کنیم؟</h3><p class="text-muted">${esc(guide.colour)}</p></section></div>
<section class="editorial-recommended"><h3>پیشنهادهای منتخب</h3><div>${featured.map((product) => `<a href="/products/${esc(product.slug)}/">${esc(displayNameWithoutShade(product.name, Boolean(product.variantGroupId)))}</a>`).join("")}</div></section>
<section class="editorial-related"><h3>دسته‌های مرتبط</h3>${guide.related.map((item) => `<a href="${esc(item.href)}">${esc(item.label)} <span>←</span></a>`).join("")}</section>
<section class="editorial-faq"><h3>سوالات متداول</h3>${guide.faq.map((item) => `<details class="faq-item"><summary>${esc(item.q)}</summary><p>${esc(item.a)}</p></details>`).join("")}</section>
<div class="editorial-cta"><p>هنوز مطمئن نیستید؟</p><h3>تمام ${esc(guide.typeFa)}‌های KIKO Milano را ببینید</h3><a class="btn" href="#products">مشاهده همه محصولات</a></div>
</div></section>
</div>`;
}
function categoryLandingFor(key?: string) {
const pages = {
"face-cleasing": {
h1: "خرید محصولات پاک‌کننده صورت کیکو میلانو",
title: "خرید محصولات پاک‌کننده صورت کیکو میلانو | کیکومیکو",
guideTitle: "راهنمای خرید پاک‌کننده صورت کیکو میلانو",
intro: "پاک‌کننده‌های صورت کیکو میلانو برای پاک‌کردن آرایش، آلودگی و چربی روزانه طراحی شده‌اند تا پوست برای ادامهٔ روتین مراقبت آماده و تازه بماند.",
types: [
{ label: "میسلار واتر", query: "micellar" },
{ label: "ژل و فوم شوینده", query: "cleanser" },
{ label: "روغن و بالم پاک‌کننده", query: "cleansing oil" },
{ label: "دستمال پاک‌کننده آرایش", query: "makeup remover" },
],
guide: "برای پاک‌سازی روزانه، بافت محصول را متناسب با نوع پوست و میزان آرایش انتخاب کنید: میسلار واتر گزینه‌ای سبک و سریع است، ژل و فوم برای شست‌وشوی صورت مناسب‌اند و روغن یا بالم پاک‌کننده به حل‌کردن آرایش مقاوم کمک می‌کنند. پس از پاک‌سازی، روتین آبرسانی پوست را ادامه دهید.",
},
"lips-make-up": {
h1: "خرید محصولات آرایش لب کیکو میلانو",
title: "خرید رژ لب و لیپ گلاس کیکو میلانو اصل | KIKO Milano",
guideTitle: "راهنمای خرید محصولات آرایش لب کیکو میلانو",
intro: "برای انتخاب آرایش لب کیکو میلانو، از رژ لب‌های مات و کرمی تا لیپ گلاس، روغن لب و بالم لب، رنگ و بافتی را انتخاب کنید که با آرایش روزانه یا مهمانی شما هماهنگ باشد.",
types: [
{ label: "رژ لب کیکو", query: "lipstick" },
{ label: "لیپ گلاس کیکو", query: "lip gloss" },
{ label: "مداد لب کیکو", query: "lip pencil" },
{ label: "Lip Oil", query: "lip oil" },
{ label: "Lip Balm", query: "lip balm" },
],
guide: "برای نتیجه‌ای مخملی و ماندگار، finish مات را انتخاب کنید؛ بافت کرمی برای رنگ‌دهی راحت و حس نرم مناسب است و لیپ گلاس یا Lip Oil جلوه‌ای براق و حجیم‌تر می‌دهد. برای انتخاب رنگ، ته‌رنگ پوست و استفادهٔ روزانه یا مهمانی را در نظر بگیرید و پیش از رژ لب، خط لب هم‌خانواده را برای فرم‌دهی دقیق‌تر استفاده کنید.",
},
"eyes-make-up": {
h1: "خرید محصولات آرایش چشم کیکو میلانو",
title: "خرید محصولات آرایش چشم کیکو میلانو اصل | KIKO Milano",
guideTitle: "راهنمای خرید محصولات آرایش چشم کیکو میلانو",
intro: "از ریمل و خط چشم تا سایه، مداد چشم و محصولات ابرو، آرایش چشم کیکو میلانو برای ساختن ظاهرهای طبیعی روزانه تا چشمگیر شبانه طراحی شده است.",
types: [
{ label: "ریمل کیکو", query: "mascara" },
{ label: "خط چشم کیکو", query: "eyeliner" },
{ label: "سایه چشم کیکو", query: "eyeshadow" },
{ label: "مداد چشم کیکو", query: "eye pencil" },
{ label: "محصولات ابرو", query: "eyebrow" },
],
guide: "برای آرایش طبیعی، رنگ‌های نود و satin را انتخاب کنید؛ finish مات برای عمق دادن به پلک مناسب است و سایه‌های shimmer یا metallic نور بیشتری به چشم می‌دهند. خط چشم مدادی برای محو کردن آسان‌تر است و خط چشم مایع یا ماژیکی برای خطوط دقیق و ماندگار انتخاب بهتری است.",
},
} as const;
return key ? pages[key as keyof typeof pages] : undefined;
}
try {
main();
} catch (err) {
console.error(err);
process.exit(1);
}