Polish storefront SEO, specials, and homepage UX.

Publish Meshkee sitemap-config, map product-source specials without variants, restore hero autoplay, and tighten page heroes, product images, and section order.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-22 21:48:00 +03:30
co-authored by Cursor
parent 11d0ba64ed
commit 24b5649abd
22 changed files with 735 additions and 298 deletions
+12
View File
@@ -0,0 +1,12 @@
### Sitemap + robots.txt
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
- Omit templates unless this site uses non-default detail URLs.
- After deploy: business dashboard → Website → Settings → Sync sitemap config (optional for CMS URLs; needed for static pages).
### On-page SEO (every public page)
- Unique <title> and meta description on every crawlable page
- Exactly one H1; H2/H3 for real sections
- All content images must have meaningful alt (CMS title when available)
- Prefer CMS fields for titles/descriptions; never leave Next defaults
+13 -2
View File
@@ -48,11 +48,12 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the
| `/about` | About us |
| `/contact` | Contact us |
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button |
| `/meshkee/sitemap-config.json` | Build-time route manifest for Meshkee sitemap sync |
| `/api/search?q=` | Instant product search (`store-items?name=`, min 3 chars) |
## Homepage order
`Hero` → `Services` → three product carousels → `CategoryShowcase` → `AboutPreview` → five-banner (2+3) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`.
`Hero` → `Services` → اقلام مصرفی → `CategoryShowcase` → قطعات یخچال → `AboutPreview` → قطعات ماشین لباسشویی → five-banner (2+3) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`.
## Homepage carousels (store specials)
@@ -62,7 +63,9 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the
2. `refrigrator-parts` — قطعات یخچال
3. `washing-machine-parts` — قطعات ماشین لباسشویی
If a special has no `items`, keep sample products from `lib/data.ts`. Use `variants[0].id` as the product URL.
BRB specials use `source: "product"` (items may have empty `variants[]`). Map product fields directly; resolve slug via catalog for detail links. If a special has no `items`, keep sample products from `lib/data.ts`.
Products list/search: prefer store-items; if empty, fall back to `GET /products` catalog.
## Catalog
@@ -89,6 +92,14 @@ If a special has no `items`, keep sample products from `lib/data.ts`. Use `varia
`url` → img, `linkUrl` → wrap in a link, empty/fail → placeholder. Use `slot.aspectRatio`. If a slot’s size/ratio changes in CSS, update `STATIC_IMAGE_SLOTS` in the same change.
## SEO
- Do **not** ship `public/sitemap.xml` or `public/robots.txt` (nginx proxies to Meshkee API).
- Build generates `lib/sitemap-config.generated.json` (`npm run generate:sitemap-config` / `prebuild`); served at `GET /meshkee/sitemap-config.json`.
- After deploy: business dashboard → Website → Settings → Sync sitemap config.
- Templates omitted (defaults `/products/{slug}`, `/blog/{slug}` match this site).
- Every public page has unique title/description; product/blog prefer CMS fields.
## Original business data (do not invent replacements)
**About:** 30+ years. Sells home appliances, spare parts, and repairs small appliances. Wholesale and retail.
+36 -5
View File
@@ -7,12 +7,43 @@ export async function GET(request: Request) {
return Response.json({ items: [] });
}
const encoded = encodeURIComponent(query);
const base = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}`;
try {
const response = await fetch(
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/store-items?name=${encodeURIComponent(query)}&pageSize=8`,
{ cache: "no-store" },
);
return Response.json(await response.json());
const storeResponse = await fetch(`${base}/store-items?name=${encoded}&pageSize=8`, {
cache: "no-store",
});
const storeData = (await storeResponse.json()) as { items?: unknown[] };
if (storeData.items?.length) {
return Response.json(storeData);
}
const catalogResponse = await fetch(`${base}/products?name=${encoded}&pageSize=8`, {
cache: "no-store",
});
const catalogData = (await catalogResponse.json()) as {
items?: Array<{
id: string;
nameFa?: string | null;
title?: string | null;
slug?: string | null;
thumbnailUrl?: string | null;
image?: string | null;
store?: { minPrice?: number | null; maxPrice?: number | null };
}>;
};
return Response.json({
items: (catalogData.items ?? []).map((item) => ({
id: item.slug || item.id,
productId: item.id,
productNameFa: item.nameFa,
productTitle: item.title,
productImage: item.thumbnailUrl || item.image,
price: item.store?.minPrice ?? item.store?.maxPrice ?? null,
})),
});
} catch {
return Response.json({ items: [] });
}
+2 -2
View File
@@ -40,7 +40,7 @@ export default async function BlogPostPage({
<PageHero kicker="Blog" title={apiPost.title} image={blogImage(apiPost)} />
<section className="py-16 sm:py-24">
<Container className="max-w-3xl">
<p className="font-latin text-[12px] tracking-[0.18em] text-muted">
<p className="text-[13px] text-muted">
{formatFaDate(apiPost.publishedAt || apiPost.createdAt)}
</p>
{apiPost.abstract ? <p className="mt-6 text-[16px] leading-9 text-muted">{apiPost.abstract}</p> : null}
@@ -69,7 +69,7 @@ export default async function BlogPostPage({
<PageHero kicker="Blog" title={post.title} image={post.image} />
<section className="py-16 sm:py-24">
<Container className="max-w-3xl">
<p className="font-latin text-[12px] tracking-[0.18em] text-muted">{post.date}</p>
<p className="text-[13px] text-muted">{post.date}</p>
<div className="mt-8 space-y-5 text-[16px] leading-9 text-muted">
{post.body.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
+17 -9
View File
@@ -29,22 +29,26 @@ export default async function BlogPage({
return (
<>
<PageHero kicker="Blog" title="مقالات" image="/images/slides/slide-2.jpg" />
<PageHero kicker="Blog" title="مقالات" image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg" />
<section className="py-16 sm:py-24">
<Container>
{usingApi ? (
<>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{result.items.map((post) => (
<Link key={post.id} href={blogHref(post)} className="group block">
<div className="overflow-hidden rounded-2xl">
<Link
key={post.id}
href={blogHref(post)}
className="group block rounded-2xl border border-line bg-white p-3"
>
<div className="overflow-hidden rounded-xl">
<Placeholder
src={blogImage(post)}
alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">
<p className="mt-3 text-[12px] text-muted">
{formatFaDate(post.publishedAt || post.createdAt)}
</p>
<h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2>
@@ -72,17 +76,21 @@ export default async function BlogPage({
) : null}
</>
) : (
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`} className="group block">
<div className="overflow-hidden rounded-2xl">
<Link
key={post.slug}
href={`/blog/${post.slug}`}
className="group block rounded-2xl border border-line bg-white p-3"
>
<div className="overflow-hidden rounded-xl">
<Placeholder
src={post.image}
alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p>
<p className="mt-3 text-[12px] text-muted">{post.date}</p>
<h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2>
<p className="mt-2 line-clamp-2 text-[14px] leading-7 text-muted">{post.excerpt}</p>
</Link>
+2
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header";
import { TopBar } from "@/components/layout/TopBar";
import { getNavCategories } from "@/lib/categories";
import { websiteDomain } from "@/lib/config";
import { site } from "@/lib/data";
const vazir = Vazirmatn({
@@ -22,6 +23,7 @@ const montserrat = Montserrat({
});
export const metadata: Metadata = {
metadataBase: new URL(`https://${websiteDomain}`),
title: {
default: `${site.name} | ${site.tagline}`,
template: `%s | ${site.name}`,
+17
View File
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+11 -2
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { AboutPreview } from "@/components/home/AboutPreview";
import { BannerRow } from "@/components/home/BannerRow";
import { BlogCarousel } from "@/components/home/BlogCarousel";
@@ -17,6 +18,7 @@ import {
refrigeratorPartsFallback,
services,
showcaseCategories,
site,
slides,
threeBannerRow,
twoBannerRow,
@@ -28,6 +30,13 @@ import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static
export const revalidate = 60;
export const metadata: Metadata = {
title: {
absolute: `${site.name} | ${site.tagline}`,
},
description: site.description,
};
export default async function HomePage() {
const [slots, consumables, refrigerator, washing, blogs] = await Promise.all([
fetchStaticImageSlots("home"),
@@ -141,20 +150,20 @@ export default async function HomePage() {
subtitle="قطعات مصرفی پرفروش برای تعمیرگاه و مصرف‌کننده"
products={consumables.products.length ? consumables.products : consumablesFallback}
/>
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
<ProductCarousel
id="refrigerator-parts"
title={refrigerator.special?.title || "قطعات یخچال"}
subtitle="قطعات یخچال و فریزر"
products={refrigerator.products.length ? refrigerator.products : refrigeratorPartsFallback}
/>
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
<ProductCarousel
id="washing-machine-parts"
title={washing.special?.title || "قطعات ماشین لباسشویی"}
subtitle="قطعات لباسشویی خانگی و صنعتی"
products={washing.products.length ? washing.products : washingMachinePartsFallback}
/>
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
<BannerRow banners={fiveBanners.items.slice(0, 2)} columns={2} aspectRatio={fiveBanners.aspectRatio} />
<BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} />
<BlogCarousel posts={blogs.items} fallback={blogPosts} />
+83 -17
View File
@@ -8,45 +8,108 @@ import { fetchComments } from "@/lib/comments";
import { formatPrice } from "@/lib/format";
import {
brandName,
fetchCatalogProductBySlug,
fetchCatalogProductByTitle,
fetchStoreItemByProduct,
fetchStoreVariant,
galleryUrls,
variantTitle,
type CatalogProduct,
type StoreVariant,
} from "@/lib/store";
export const revalidate = 60;
async function resolveProduct(id: string): Promise<{
variant: StoreVariant | null;
catalog: CatalogProduct | null;
productId?: string;
}> {
const variant = await fetchStoreVariant(id);
if (variant) {
const catalog = await fetchCatalogProductByTitle(
variant.productNameFa || variant.productTitle,
variant.productId,
);
return { variant, catalog, productId: variant.productId };
}
const bySlug = await fetchCatalogProductBySlug(id);
if (bySlug) {
return { variant: null, catalog: bySlug, productId: bySlug.id };
}
const byProduct = await fetchStoreItemByProduct(id);
const firstVariant = byProduct.storeItem?.variants?.[0];
if (firstVariant) {
const catalog = await fetchCatalogProductByTitle(
firstVariant.productNameFa || firstVariant.productTitle,
id,
);
return {
variant: {
...firstVariant,
productId: id,
},
catalog,
productId: id,
};
}
return { variant: null, catalog: null };
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const variant = await fetchStoreVariant(id);
const resolved = await resolveProduct(id);
const title =
resolved.catalog?.nameFa ||
resolved.catalog?.title ||
(resolved.variant ? variantTitle(resolved.variant) : null);
const description =
resolved.catalog?.summary?.trim() ||
stripHtml(resolved.catalog?.descriptionHtml) ||
undefined;
return {
title: variant ? variantTitle(variant) : "محصول",
title: title || "محصول",
description,
};
}
function stripHtml(html?: string | null) {
if (!html?.trim()) return undefined;
const text = html
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return text.slice(0, 180) || undefined;
}
export default async function ProductDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const variant = await fetchStoreVariant(id);
if (!variant) notFound();
const { variant, catalog, productId } = await resolveProduct(id);
if (!variant && !catalog) notFound();
const [catalog, storeItem, comments] = await Promise.all([
fetchCatalogProductByTitle(variant.productNameFa || variant.productTitle, variant.productId),
variant.productId ? fetchStoreItemByProduct(variant.productId) : Promise.resolve({ storeItem: undefined }),
variant.productId ? fetchComments("product", variant.productId) : Promise.resolve([]),
]);
const comments = productId ? await fetchComments("product", productId) : [];
const sibling =
productId && variant
? await fetchStoreItemByProduct(productId)
: { storeItem: undefined };
const images = galleryUrls(catalog, variant.productImage);
const price = variant.discountedPrice ?? variant.price;
const oldPrice = variant.discountedPrice != null ? variant.price : null;
const title = catalog?.nameFa || variantTitle(variant);
const images = galleryUrls(catalog, variant?.productImage || catalog?.image || catalog?.thumbnailUrl);
const price = variant?.discountedPrice ?? variant?.price ?? catalog?.store?.minPrice ?? null;
const oldPrice = variant?.discountedPrice != null ? variant.price : null;
const title = catalog?.nameFa || catalog?.title || (variant ? variantTitle(variant) : "محصول");
const brand = brandName(catalog);
const variants = storeItem.storeItem?.variants ?? [];
const inStock = variant.stockQuantity == null || variant.stockQuantity > 0;
const variants = sibling.storeItem?.variants ?? [];
const inStock =
variant != null
? variant.stockQuantity == null || variant.stockQuantity > 0
: catalog?.store?.inStock !== false;
return (
<section className="py-10 sm:py-14">
@@ -67,8 +130,11 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
<ProductGallery images={images} alt={title} />
<div>
{brand ? <p className="text-[13px] font-medium text-brand">{brand}</p> : null}
{catalog?.categoryNameFa ? (
<p className="mt-1 text-[12px] text-muted">{catalog.categoryNameFa}</p>
) : null}
<h1 className="mt-2 text-[22px] font-extrabold leading-9 text-ink">{title}</h1>
{variant.label && variant.label !== title ? (
{variant?.label && variant.label !== title ? (
<p className="mt-2 text-[13px] text-muted">{variant.label}</p>
) : null}
@@ -95,7 +161,7 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
<Link
key={item.id}
href={`/products/${encodeURIComponent(item.id)}`}
className={`rounded-lg border px-3 py-1.5 text-[12px] ${item.id === variant.id ? "border-brand bg-paper text-ink" : "border-line bg-white text-muted"}`}
className={`rounded-lg border px-3 py-1.5 text-[12px] ${item.id === variant?.id ? "border-brand bg-paper text-ink" : "border-line bg-white text-muted"}`}
>
{item.label || variantTitle(item)}
</Link>
+27 -18
View File
@@ -1,10 +1,16 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container } from "@/components/Container";
import { PageHero } from "@/components/layout/PageHero";
import { ProductCard } from "@/components/ui/ProductCard";
import { fetchStoreItems, variantToProduct } from "@/lib/store";
import { getNavCategories } from "@/lib/categories";
import { faDigits } from "@/lib/format";
import {
catalogProductToCard,
fetchCatalogProducts,
fetchStoreItems,
variantToProduct,
} from "@/lib/store";
export const revalidate = 60;
@@ -22,32 +28,34 @@ export default async function ProductsPage({
const page = Math.max(1, Number(params.page) || 1);
const categoryId = params.categoryId;
const q = params.q?.trim();
const [result, nav] = await Promise.all([
fetchStoreItems({ page, pageSize: 20, categoryId, name: q && q.length > 2 ? q : undefined }),
const name = q && q.length > 2 ? q : undefined;
const [storeResult, catalogResult, nav] = await Promise.all([
fetchStoreItems({ page, pageSize: 20, categoryId, name }),
fetchCatalogProducts({ page, pageSize: 20, categoryId, name }),
getNavCategories(),
]);
const products = result.items.map(variantToProduct);
const pageCount = Math.max(1, Math.ceil((result.total || products.length) / (result.pageSize || 20)));
const fromStore = storeResult.items.map(variantToProduct);
const products = fromStore.length ? fromStore : catalogResult.items.map(catalogProductToCard);
const total = fromStore.length ? storeResult.total : catalogResult.total;
const pageSize = fromStore.length ? storeResult.pageSize : catalogResult.pageSize;
const pageCount = Math.max(1, Math.ceil((total || products.length) / (pageSize || 20)));
const categoryTitle = nav
.flatMap((item) => [item, ...item.children.map((child) => ({ ...child, children: [] as never[] }))])
.find((item) => item.id === categoryId)?.title;
const heroTitle = categoryTitle || (q ? `جستجو: ${q}` : "محصولات");
return (
<>
<PageHero
kicker="Products"
title={heroTitle}
image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg"
/>
<section className="py-10 sm:py-14">
<Container>
<div className="mb-6">
<p className="text-[13px] text-muted">
<Link href="/" className="hover:text-brand">
خانه
</Link>
<span className="mx-2">/</span>
محصولات
</p>
<h1 className="mt-2 text-[22px] font-extrabold text-ink">
{categoryTitle || (q ? `جستجو: ${q}` : "همه محصولات")}
</h1>
{result.total ? <p className="mt-1 text-[13px] text-muted">{faDigits(result.total)} کالا</p> : null}
</div>
{total ? <p className="mb-6 text-[13px] text-muted">{faDigits(total)} کالا</p> : null}
{products.length === 0 ? (
<div className="rounded-2xl border border-line bg-white px-6 py-16 text-center">
@@ -84,5 +92,6 @@ export default async function ProductsPage({
) : null}
</Container>
</section>
</>
);
}
+20 -14
View File
@@ -37,18 +37,14 @@ export function BlogCarousel({
return (
<section id="blog" className="py-20 sm:py-24">
<Container>
<div className="mb-10 flex items-end justify-between gap-4">
<div>
<div className="mb-10">
<div className="flex items-center gap-3">
<span className="h-[3px] w-8 bg-brand" />
<p className="section-kicker">Blog</p>
</div>
<h2 className="mt-3 text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2>
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
نکته‌هایی از تعمیرگاه و بورس قطعات — برای مصرف‌کننده و همکار.
</p>
</div>
<div className="flex items-center gap-2">
<div className="mt-3 flex items-center justify-between gap-4">
<h2 className="text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => scrollByCard(1)}
@@ -70,20 +66,27 @@ export function BlogCarousel({
</Link>
</div>
</div>
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
نکته‌هایی از تعمیرگاه و بورس قطعات — برای مصرف‌کننده و همکار.
</p>
</div>
<div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto">
{usingApi
? posts.map((post) => (
<article key={post.id} className="w-[min(85vw,340px)] shrink-0 snap-start">
<article
key={post.id}
className="w-[min(85vw,340px)] shrink-0 snap-start rounded-2xl border border-line bg-white p-3"
>
<Link href={blogHref(post)} className="group block">
<div className="overflow-hidden rounded-2xl">
<div className="overflow-hidden rounded-xl">
<Placeholder
src={blogImage(post)}
alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">
<p className="mt-3 text-[12px] text-muted">
{formatFaDate(post.publishedAt || post.createdAt)}
</p>
<h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3>
@@ -94,16 +97,19 @@ export function BlogCarousel({
</article>
))
: fallback.map((post) => (
<article key={post.slug} className="w-[min(85vw,340px)] shrink-0 snap-start">
<article
key={post.slug}
className="w-[min(85vw,340px)] shrink-0 snap-start rounded-2xl border border-line bg-white p-3"
>
<Link href={`/blog/${post.slug}`} className="group block">
<div className="overflow-hidden rounded-2xl">
<div className="overflow-hidden rounded-xl">
<Placeholder
src={post.image}
alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p>
<p className="mt-3 text-[12px] text-muted">{post.date}</p>
<h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3>
<p className="mt-2 line-clamp-2 text-[13px] leading-7 text-muted">{post.excerpt}</p>
</Link>
+109 -45
View File
@@ -1,72 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
function circularOffset(index: number, active: number, length: number) {
let diff = index - active;
if (diff > length / 2) diff -= length;
if (diff < -length / 2) diff += length;
return diff;
}
const SLIDE_W = "min(1280px, calc(100vw - 5rem))";
const GAP = "1rem";
const INTERVAL_MS = 6000;
const TRANSITION_MS = 700;
const slideWidth = "min(1280px, calc(100vw - 5rem))";
export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectRatio?: string }) {
export function Hero({ slides }: { slides: SlotMediaItem[]; aspectRatio?: string }) {
const [active, setActive] = useState(0);
const [shift, setShift] = useState(0);
const [animating, setAnimating] = useState(false);
const [paused, setPaused] = useState(false);
const ratio = parseAspectRatio(aspectRatio);
const lock = useRef(false);
const shiftRef = useRef(0);
const countRef = useRef(slides.length);
countRef.current = slides.length;
const finishTransition = () => {
if (!lock.current) return;
lock.current = false;
const delta = shiftRef.current;
const total = countRef.current;
shiftRef.current = 0;
setActive((current) => (current + delta + total) % total);
setShift(0);
setAnimating(false);
};
const go = (direction: -1 | 1) => {
if (countRef.current < 2 || lock.current) return;
lock.current = true;
shiftRef.current = direction;
setAnimating(true);
setShift(direction);
window.setTimeout(() => {
if (lock.current && shiftRef.current === direction) finishTransition();
}, TRANSITION_MS + 80);
};
useEffect(() => {
setActive(0);
setShift(0);
shiftRef.current = 0;
setAnimating(false);
lock.current = false;
}, [slides.length]);
useEffect(() => {
if (slides.length < 2 || paused) return;
const timer = setInterval(() => {
setActive((current) => (current + 1) % slides.length);
}, 6000);
return () => clearInterval(timer);
}, [paused, slides.length]);
const go = (direction: number) => {
if (slides.length < 2) return;
setActive((current) => (current + direction + slides.length) % slides.length);
};
if (slides.length < 2 || paused || animating) return;
const timer = window.setInterval(() => go(1), INTERVAL_MS);
return () => window.clearInterval(timer);
}, [paused, animating, slides.length, active]);
if (slides.length === 0) return null;
const count = slides.length;
const offsets =
count === 1
? ([0] as number[])
: shift > 0
? ([-1, 0, 1, 2] as number[])
: shift < 0
? ([-2, -1, 0, 1] as number[])
: ([-1, 0, 1] as number[]);
return (
<section className="relative overflow-x-clip bg-white pt-5 pb-8">
<div
className={ratio ? "relative min-h-[340px]" : "relative h-[min(62vh,640px)] min-h-[340px]"}
style={ratio ? { aspectRatio: ratio, maxHeight: 640 } : undefined}
className="relative h-[min(62vh,640px)] min-h-[340px]"
style={{ ["--slide-w" as string]: SLIDE_W, ["--slide-gap" as string]: GAP }}
dir="ltr"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{slides.map((item, i) => {
const offset = circularOffset(i, active, slides.length);
const isCenter = offset === 0;
const isPeek = Math.abs(offset) === 1;
<div
className="absolute inset-0"
style={{
transform: `translateX(calc(${-shift} * (var(--slide-w) + var(--slide-gap))))`,
transition: animating
? `transform ${TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
: "none",
}}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return;
if (event.propertyName !== "transform") return;
finishTransition();
}}
>
{offsets.map((offset) => {
const index = (active + offset + count * 10) % count;
const item = slides[index];
const isActiveSlot = offset === shift;
return (
<article
key={item.id}
className="absolute top-0 h-full w-[min(1280px,calc(100vw-5rem))]"
key={`slot-${offset}`}
className="absolute top-0 h-full"
style={{
left: `calc(50% - (${slideWidth}) / 2 + ${offset} * ((${slideWidth}) + 1rem))`,
zIndex: isCenter ? 3 : isPeek ? 2 : 1,
opacity: isCenter || isPeek ? 1 : 0,
pointerEvents: isCenter || isPeek ? "auto" : "none",
transition: "left 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease",
width: "var(--slide-w)",
left: `calc(50% - var(--slide-w) / 2 + ${offset} * (var(--slide-w) + var(--slide-gap)))`,
zIndex: isActiveSlot ? 3 : 2,
}}
>
{isCenter ? (
{isActiveSlot ? (
<SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl">
<Placeholder
src={item.image}
@@ -89,7 +130,7 @@ export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectR
<div dir="rtl" className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3 sm:gap-5">
<h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]">
{item.title}
{item.title || "فروشگاه برادران برقعی"}
</h1>
<span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" />
<span className="hidden h-6 w-px shrink-0 bg-white/20 sm:block" />
@@ -109,22 +150,34 @@ export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectR
<button
type="button"
className="relative block h-full w-full overflow-hidden rounded-2xl"
onClick={() => setActive(i)}
onClick={() => go(offset < shift ? -1 : 1)}
aria-label={item.title}
disabled={animating}
>
<Placeholder src={item.image} fallbackSrc={item.fallbackSrc} alt="" className="h-full w-full object-cover" />
<Placeholder
src={item.image}
fallbackSrc={item.fallbackSrc}
alt={item.title ?? ""}
className="h-full w-full object-cover"
/>
<span className="absolute inset-0 bg-white/35" />
</button>
)}
</article>
);
})}
</div>
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-[min(1280px,calc(100vw-5rem))] -translate-x-1/2">
{count > 1 ? (
<div
className="pointer-events-none absolute inset-y-0 left-1/2 z-10 -translate-x-1/2"
style={{ width: "var(--slide-w)" }}
>
<button
type="button"
onClick={() => go(1)}
className="pointer-events-auto absolute top-1/2 right-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm"
disabled={animating}
className="pointer-events-auto absolute top-1/2 right-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm disabled:opacity-60"
aria-label="اسلاید راست"
>
<ChevronRight className="size-5" />
@@ -132,25 +185,36 @@ export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectR
<button
type="button"
onClick={() => go(-1)}
className="pointer-events-auto absolute top-1/2 left-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm"
disabled={animating}
className="pointer-events-auto absolute top-1/2 left-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm disabled:opacity-60"
aria-label="اسلاید چپ"
>
<ChevronLeft className="size-5" />
</button>
</div>
) : null}
</div>
{count > 1 ? (
<div className="relative z-10 mt-5 flex justify-center gap-2">
{slides.map((item, i) => (
<button
key={item.id}
type="button"
onClick={() => setActive(i)}
className={`h-1.5 rounded-full transition-all ${i === active ? "w-8 bg-brand" : "w-3 bg-ink/20"}`}
disabled={animating}
onClick={() => {
if (i === active || lock.current) return;
const forward = (i - active + count) % count;
const backward = (active - i + count) % count;
if (forward <= backward) go(1);
else go(-1);
}}
className={`h-1.5 rounded-full transition-all ${i === active && shift === 0 ? "w-8 bg-brand" : "w-3 bg-ink/20"}`}
aria-label={`اسلاید ${i + 1}`}
/>
))}
</div>
) : null}
</section>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ export function Services({ items }: { items: SlotMediaItem[] }) {
<Placeholder
src={service.image}
fallbackSrc={service.fallbackSrc}
alt=""
alt={service.title ?? ""}
className="mt-1 size-16 shrink-0 object-contain sm:size-[4.5rem]"
/>
) : (
+6 -12
View File
@@ -2,7 +2,6 @@ import { Container } from "@/components/Container";
import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink";
import { glossyBar } from "@/lib/glossy";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images";
const fallbackCopy = {
@@ -11,37 +10,32 @@ const fallbackCopy = {
href: "/contact",
};
export function WideBanner({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) {
export function WideBanner({ image }: { image?: SlotMediaItem; aspectRatio?: string }) {
const src = image?.image ?? "/images/banners/wide.jpg";
const href = image?.href || fallbackCopy.href;
const ratio = parseAspectRatio(aspectRatio);
return (
<section
className={`relative overflow-hidden ${ratio ? "min-h-[280px]" : "h-[280px] sm:h-[360px]"}`}
style={ratio ? { aspectRatio: ratio } : undefined}
>
<section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]">
<SlotLink href={href} className="absolute inset-0">
<Placeholder
src={src}
fallbackSrc={image?.fallbackSrc ?? "/images/banners/wide.jpg"}
alt=""
alt={image?.title || fallbackCopy.title}
className="h-full w-full object-cover"
/>
<span className="absolute inset-0 bg-ink/20" />
</SlotLink>
<Container className="relative flex h-full items-center">
<div className="max-w-xl rounded-2xl px-8 py-7" style={glossyBar}>
<div className="max-w-xl rounded-2xl px-6 py-4 sm:px-8 sm:py-5" style={glossyBar}>
<p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">
{image?.kicker || fallbackCopy.kicker}
</p>
<p className="mt-3 text-[22px] font-bold leading-9 text-white sm:text-[26px]">
<p className="mt-1.5 line-clamp-2 text-[18px] font-bold leading-7 text-white sm:text-[22px]">
{image?.title || fallbackCopy.title}
</p>
{image?.text ? <p className="mt-2 text-[14px] leading-7 text-white/75">{image.text}</p> : null}
<SlotLink
href={href}
className="mt-5 inline-flex rounded-xl bg-brand px-6 py-3 text-[14px] font-medium text-white hover:bg-brand-dark"
className="mt-3 inline-flex rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white hover:bg-brand-dark"
>
تماس با ما
</SlotLink>
+9 -11
View File
@@ -1,6 +1,6 @@
import { GlassBox } from "@/components/GlassBox";
import { Placeholder } from "@/components/Placeholder";
import { Container } from "@/components/Container";
import { glossyBar } from "@/lib/glossy";
export function PageHero({
kicker,
@@ -12,17 +12,15 @@ export function PageHero({
image: string;
}) {
return (
<section className="relative h-[42vh] min-h-[280px] overflow-hidden bg-ink">
<Placeholder src={image} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-black/10" />
<div className="absolute inset-x-0 bottom-0">
<Container className="px-4 sm:px-6">
<GlassBox frostSrc={image} className="relative mb-6 inline-block max-w-lg px-8 py-7">
<p className="section-kicker">{kicker}</p>
<h1 className="mt-2 text-3xl font-extrabold sm:text-5xl">{title}</h1>
</GlassBox>
</Container>
<section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]">
<Placeholder src={image} alt={title} className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-ink/75" />
<Container className="relative flex h-full items-center px-4 sm:px-6">
<div className="max-w-xl rounded-2xl px-6 py-4 sm:px-8 sm:py-5" style={glossyBar}>
<p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">{kicker}</p>
<h1 className="mt-1.5 line-clamp-2 text-[22px] font-extrabold leading-snug text-white sm:text-[28px]">{title}</h1>
</div>
</Container>
</section>
);
}
+4 -4
View File
@@ -17,7 +17,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
<div>
<button
type="button"
className="relative block w-full overflow-hidden rounded-2xl bg-paper"
className="relative block aspect-square w-full overflow-hidden rounded-2xl bg-paper"
onClick={() => setLightbox(true)}
onMouseEnter={() => setZoomed(true)}
onMouseLeave={() => {
@@ -35,10 +35,10 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
src={current}
fallbackSrc="/placeholders/product.svg"
alt={alt}
className="aspect-square w-full object-contain transition-transform duration-150"
className="absolute inset-0 size-full object-cover transition-transform duration-150"
/>
<span
className="pointer-events-none absolute inset-0 bg-contain bg-center bg-no-repeat"
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${current})`,
transform: zoomed ? "scale(1.85)" : "scale(1)",
@@ -56,7 +56,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
onClick={() => setActive(index)}
className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-brand" : "border-line"}`}
>
<Placeholder src={image} className="size-full object-contain" />
<Placeholder src={image} alt="" className="size-full object-cover" />
</button>
))}
</div>
+3 -2
View File
@@ -9,7 +9,7 @@ export function ProductCard({ product }: { product: Product }) {
return (
<article className="group flex h-full flex-col rounded-2xl border border-line bg-white p-3">
<Link href={href} className="relative overflow-hidden rounded-xl bg-paper">
<Link href={href} className="relative block aspect-square overflow-hidden rounded-xl bg-paper">
{product.discount ? (
<span className="absolute top-2 right-2 z-10 rounded-md bg-brand px-1.5 py-0.5 text-[11px] font-bold text-white">
{faDigits(product.discount)}٪
@@ -18,7 +18,8 @@ export function ProductCard({ product }: { product: Product }) {
<Placeholder
src={product.image}
fallbackSrc="/placeholders/product.svg"
className="aspect-square w-full object-contain transition group-hover:scale-[1.03]"
alt={product.title}
className="absolute inset-0 size-full object-cover transition group-hover:scale-[1.03]"
/>
</Link>
{product.brand ? (
+28
View File
@@ -0,0 +1,28 @@
{
"baseUrl": "https://brb-store.com",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/about",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/contact",
"priority": 0.5
},
{
"path": "/products",
"changefreq": "daily",
"priority": 0.9
}
]
}
+1 -1
View File
@@ -104,7 +104,7 @@ export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
label: "Full-row contact banner",
kind: "single",
pageKey: "home",
aspectRatio: "32:9",
aspectRatio: "64:9",
itemCount: 1,
recommendedWidth: 1920,
},
+95 -10
View File
@@ -30,6 +30,8 @@ export type StoreSpecialProduct = {
productTitle?: string | null;
productNameFa?: string | null;
productImage?: string | null;
isActive?: boolean;
sortOrder?: number | null;
variants?: StoreVariant[];
};
@@ -48,6 +50,12 @@ export type CatalogProduct = {
categoryNameFa?: string | null;
categoryName?: string | null;
commentCount?: number;
store?: {
variantCount?: number | null;
minPrice?: number | null;
maxPrice?: number | null;
inStock?: boolean | null;
} | null;
};
export type Paged<T> = {
@@ -97,9 +105,11 @@ export function variantToProduct(variant: StoreVariant): Product {
};
}
export function specialItemToProduct(item: StoreSpecialProduct): Product | null {
export function specialItemToProduct(item: StoreSpecialProduct, slug?: string | null): Product | null {
if (item.isActive === false) return null;
const variant = item.variants?.[0];
if (!variant) return null;
if (variant) {
return variantToProduct({
...variant,
productId: item.productId ?? variant.productId,
@@ -109,6 +119,25 @@ export function specialItemToProduct(item: StoreSpecialProduct): Product | null
});
}
// Product-source specials often ship with empty variants[].
const title = item.productNameFa?.trim() || item.productTitle?.trim();
if (!title && !item.productImage) return null;
const id = String(item.productId ?? item.id);
const href = slug
? `/products/${encodeURIComponent(slug)}`
: `/products?q=${encodeURIComponent(title || id)}`;
return {
id,
href,
title: title || "محصول",
brand: "",
image: item.productImage || PLACEHOLDER,
inStock: true,
};
}
export async function fetchStoreItems(params: {
page?: number;
pageSize?: number;
@@ -144,14 +173,33 @@ export async function fetchStoreItemByProduct(productId: string) {
}
export async function fetchStoreSpecials() {
const data = await meshkeeGet<{ items?: StoreSpecial[] }>(tenantPath("/store-specials"), { items: [] });
const data = await meshkeeGet<{ items?: StoreSpecial[]; source?: string }>(tenantPath("/store-specials"), {
items: [],
});
return data.items ?? [];
}
export async function fetchSpecialProducts(key: string) {
const specials = await fetchStoreSpecials();
const special = specials.find((item) => item.key === key);
const products = (special?.items ?? []).map(specialItemToProduct).filter((item): item is Product => Boolean(item));
const rawItems = [...(special?.items ?? [])]
.filter((item) => item.isActive !== false)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const products = (
await Promise.all(
rawItems.map(async (item) => {
if (item.variants?.[0]) return specialItemToProduct(item);
const catalog = await fetchCatalogProductByTitle(
item.productNameFa || item.productTitle,
item.productId,
);
return specialItemToProduct(item, catalog?.slug);
}),
)
).filter((item): item is Product => Boolean(item));
return { special, products };
}
@@ -167,22 +215,59 @@ export function galleryUrls(product?: CatalogProduct | null, fallback?: string |
return urls.length ? urls : [PLACEHOLDER];
}
export async function fetchCatalogProducts(params: {
page?: number;
pageSize?: number;
categoryId?: string;
name?: string;
} = {}) {
const query = new URLSearchParams();
query.set("page", String(params.page ?? 1));
query.set("pageSize", String(params.pageSize ?? 20));
if (params.categoryId) query.set("categoryId", params.categoryId);
if (params.name) query.set("name", params.name);
return meshkeeGet<Paged<CatalogProduct>>(tenantPath(`/products?${query.toString()}`), {
items: [],
total: 0,
page: 1,
pageSize: params.pageSize ?? 20,
});
}
export function catalogProductToCard(product: CatalogProduct): Product {
const price = asNumber(product.store?.minPrice);
return {
id: String(product.id),
href: product.slug ? `/products/${encodeURIComponent(product.slug)}` : `/products?q=${encodeURIComponent(product.nameFa || product.title || product.id)}`,
title: product.nameFa?.trim() || product.title?.trim() || "محصول",
brand: brandName(product) || product.categoryNameFa || "",
price: price ?? undefined,
image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER,
inStock: product.store?.inStock !== false,
};
}
export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) {
if (!title?.trim()) return null;
if (!title?.trim() && !productId) return null;
const query = title?.trim() || String(productId);
const data = await meshkeeGet<Paged<CatalogProduct>>(
tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=8`),
{ items: [], total: 0, page: 1, pageSize: 8 },
tenantPath(`/products?name=${encodeURIComponent(query)}&pageSize=12`),
{ items: [], total: 0, page: 1, pageSize: 12 },
);
const match =
data.items.find((item) => String(item.id) === String(productId)) ??
data.items[0] ??
null;
if (!match?.slug) return match;
const detail = await meshkeeGet<{ product?: CatalogProduct }>(
tenantPath(`/products/${encodeURIComponent(match.slug)}`),
return fetchCatalogProductBySlug(match.slug);
}
export async function fetchCatalogProductBySlug(slug: string) {
const data = await meshkeeGet<{ product?: CatalogProduct }>(
tenantPath(`/products/${encodeURIComponent(slug)}`),
{},
);
return detail.product ?? match;
return data.product ?? null;
}
export function brandName(product?: CatalogProduct | null) {
+3 -1
View File
@@ -3,10 +3,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"lucide-react": "^0.539.0",
+94
View File
@@ -0,0 +1,94 @@
/**
* Scans app page routes and writes lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "app");
const outFile = path.join(root, "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
]);
const PRIORITY = {
"/": { changefreq: "daily", priority: 1.0 },
"/products": { changefreq: "daily", priority: 0.9 },
"/blog": { changefreq: "weekly", priority: 0.8 },
"/about": { priority: 0.6 },
"/contact": { priority: 0.5 },
};
function websiteDomain() {
return (
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ||
"brb-store.com"
);
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
// Skip dynamic segments and route groups for staticPages; groups recurse without path.
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (entry.name === "page.tsx" || entry.name === "page.ts" || entry.name === "page.jsx" || entry.name === "page.js") {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`);
}
main();