+
+
-
{post.date}
+
{post.date}
{post.title}
{post.excerpt}
diff --git a/app/layout.tsx b/app/layout.tsx
index 06ec00f..1d593bf 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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}`,
diff --git a/app/meshkee/sitemap-config.json/route.ts b/app/meshkee/sitemap-config.json/route.ts
new file mode 100644
index 0000000..998558b
--- /dev/null
+++ b/app/meshkee/sitemap-config.json/route.ts
@@ -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 });
+}
diff --git a/app/page.tsx b/app/page.tsx
index 8891dc4..9b6253c 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -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}
/>
+
+
-
-
diff --git a/app/products/[id]/page.tsx b/app/products/[id]/page.tsx
index 8caf13d..2b15d1d 100644
--- a/app/products/[id]/page.tsx
+++ b/app/products/[id]/page.tsx
@@ -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
{
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 (
@@ -67,8 +130,11 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
{brand ?
{brand}
: null}
+ {catalog?.categoryNameFa ? (
+
{catalog.categoryNameFa}
+ ) : null}
{title}
- {variant.label && variant.label !== title ? (
+ {variant?.label && variant.label !== title ? (
{variant.label}
) : null}
@@ -95,7 +161,7 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
{item.label || variantTitle(item)}
diff --git a/app/products/page.tsx b/app/products/page.tsx
index 95038fe..04c5642 100644
--- a/app/products/page.tsx
+++ b/app/products/page.tsx
@@ -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,67 +28,70 @@ 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 (
-
-
-
-
-
- خانه
-
- /
- محصولات
-
-
- {categoryTitle || (q ? `جستجو: ${q}` : "همه محصولات")}
-
- {result.total ?
{faDigits(result.total)} کالا
: null}
-
+ <>
+
+
+
+ {total ? {faDigits(total)} کالا
: null}
- {products.length === 0 ? (
-
-
محصولی برای نمایش وجود ندارد
-
بهزودی موجودی فروشگاه از پنل Meshkee اینجا دیده میشود.
-
- ) : (
-
- {products.map((product) => (
-
- ))}
-
- )}
+ {products.length === 0 ? (
+
+
محصولی برای نمایش وجود ندارد
+
بهزودی موجودی فروشگاه از پنل Meshkee اینجا دیده میشود.
+
+ ) : (
+
+ {products.map((product) => (
+
+ ))}
+
+ )}
- {pageCount > 1 ? (
-
- {Array.from({ length: pageCount }, (_, index) => {
- const number = index + 1;
- const query = new URLSearchParams();
- if (categoryId) query.set("categoryId", categoryId);
- if (q) query.set("q", q);
- query.set("page", String(number));
- return (
-
- {faDigits(number)}
-
- );
- })}
-
- ) : null}
-
-
+ {pageCount > 1 ? (
+
+ {Array.from({ length: pageCount }, (_, index) => {
+ const number = index + 1;
+ const query = new URLSearchParams();
+ if (categoryId) query.set("categoryId", categoryId);
+ if (q) query.set("q", q);
+ query.set("page", String(number));
+ return (
+
+ {faDigits(number)}
+
+ );
+ })}
+
+ ) : null}
+
+
+ >
);
}
diff --git a/components/home/BlogCarousel.tsx b/components/home/BlogCarousel.tsx
index 987d247..7f7387f 100644
--- a/components/home/BlogCarousel.tsx
+++ b/components/home/BlogCarousel.tsx
@@ -37,53 +37,56 @@ export function BlogCarousel({
return (
-
-
-
-
-
Blog
+
+
+
+
آخرین مقالات
+
+ scrollByCard(1)}
+ className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
+ aria-label="قبلی"
+ >
+
+
+ scrollByCard(-1)}
+ className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
+ aria-label="بعدی"
+ >
+
+
+
+ مشاهده همه
+
-
آخرین مقالات
-
- نکتههایی از تعمیرگاه و بورس قطعات — برای مصرفکننده و همکار.
-
-
-
- scrollByCard(1)}
- className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
- aria-label="قبلی"
- >
-
-
- scrollByCard(-1)}
- className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
- aria-label="بعدی"
- >
-
-
-
- مشاهده همه
-
+
+ نکتههایی از تعمیرگاه و بورس قطعات — برای مصرفکننده و همکار.
+
{usingApi
? posts.map((post) => (
-
+
-
+
-
+
{formatFaDate(post.publishedAt || post.createdAt)}
{post.title}
@@ -94,16 +97,19 @@ export function BlogCarousel({
))
: fallback.map((post) => (
-
+
-
+
-
{post.date}
+
{post.date}
{post.title}
{post.excerpt}
diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx
index 0020cde..c444665 100644
--- a/components/home/Hero.tsx
+++ b/components/home/Hero.tsx
@@ -1,156 +1,220 @@
"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 (
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;
+
{
+ 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 (
-
- {isCenter ? (
-
-
-
-
-
-
- {item.title}
-
-
-
- {item.kicker ? (
-
- {item.kicker}
-
- ) : null}
+ return (
+
+ {isActiveSlot ? (
+
+
+
+
+
+
+ {item.title || "فروشگاه برادران برقعی"}
+
+
+
+ {item.kicker ? (
+
+ {item.kicker}
+
+ ) : null}
+
+
+ مشاهده قطعات
+
-
- مشاهده قطعات
-
-
-
- ) : (
-
setActive(i)}
- aria-label={item.title}
- >
-
-
-
- )}
-
- );
- })}
-
-
-
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"
- aria-label="اسلاید راست"
- >
-
-
-
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"
- aria-label="اسلاید چپ"
- >
-
-
+
+ ) : (
+
go(offset < shift ? -1 : 1)}
+ aria-label={item.title}
+ disabled={animating}
+ >
+
+
+
+ )}
+
+ );
+ })}
+
+ {count > 1 ? (
+
+ go(1)}
+ 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="اسلاید راست"
+ >
+
+
+ go(-1)}
+ 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="اسلاید چپ"
+ >
+
+
+
+ ) : null}
-
- {slides.map((item, i) => (
- setActive(i)}
- className={`h-1.5 rounded-full transition-all ${i === active ? "w-8 bg-brand" : "w-3 bg-ink/20"}`}
- aria-label={`اسلاید ${i + 1}`}
- />
- ))}
-
+ {count > 1 ? (
+
+ {slides.map((item, i) => (
+ {
+ 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}`}
+ />
+ ))}
+
+ ) : null}
);
}
diff --git a/components/home/Services.tsx b/components/home/Services.tsx
index 4161e95..e3157d2 100644
--- a/components/home/Services.tsx
+++ b/components/home/Services.tsx
@@ -23,7 +23,7 @@ export function Services({ items }: { items: SlotMediaItem[] }) {
) : (
diff --git a/components/home/WideBanner.tsx b/components/home/WideBanner.tsx
index a9df79a..aa90283 100644
--- a/components/home/WideBanner.tsx
+++ b/components/home/WideBanner.tsx
@@ -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 (
-
+
-
+
{image?.kicker || fallbackCopy.kicker}
-
+
{image?.title || fallbackCopy.title}
- {image?.text ?
{image.text}
: null}
تماس با ما
diff --git a/components/layout/PageHero.tsx b/components/layout/PageHero.tsx
index 7d955a0..06b9294 100644
--- a/components/layout/PageHero.tsx
+++ b/components/layout/PageHero.tsx
@@ -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 (
-
-
-
-
-
-
- {kicker}
- {title}
-
-
-
+
);
}
diff --git a/components/product/ProductGallery.tsx b/components/product/ProductGallery.tsx
index 210218c..b1d644b 100644
--- a/components/product/ProductGallery.tsx
+++ b/components/product/ProductGallery.tsx
@@ -17,7 +17,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
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"
/>
setActive(index)}
className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-brand" : "border-line"}`}
>
-
+
))}
diff --git a/components/ui/ProductCard.tsx b/components/ui/ProductCard.tsx
index 643e8b6..d870722 100644
--- a/components/ui/ProductCard.tsx
+++ b/components/ui/ProductCard.tsx
@@ -9,7 +9,7 @@ export function ProductCard({ product }: { product: Product }) {
return (
-
+
{product.discount ? (
{faDigits(product.discount)}٪
@@ -18,7 +18,8 @@ export function ProductCard({ product }: { product: Product }) {
{product.brand ? (
diff --git a/lib/sitemap-config.generated.json b/lib/sitemap-config.generated.json
new file mode 100644
index 0000000..04185cf
--- /dev/null
+++ b/lib/sitemap-config.generated.json
@@ -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
+ }
+ ]
+}
diff --git a/lib/static-images.ts b/lib/static-images.ts
index 77e5e05..9793dc5 100644
--- a/lib/static-images.ts
+++ b/lib/static-images.ts
@@ -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,
},
diff --git a/lib/store.ts b/lib/store.ts
index 6774501..5592636 100644
--- a/lib/store.ts
+++ b/lib/store.ts
@@ -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 = {
@@ -97,16 +105,37 @@ 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;
- return variantToProduct({
- ...variant,
- productId: item.productId ?? variant.productId,
- productTitle: item.productTitle ?? variant.productTitle,
- productNameFa: item.productNameFa ?? variant.productNameFa,
- productImage: item.productImage ?? variant.productImage,
- });
+ if (variant) {
+ return variantToProduct({
+ ...variant,
+ productId: item.productId ?? variant.productId,
+ productTitle: item.productTitle ?? variant.productTitle,
+ productNameFa: item.productNameFa ?? variant.productNameFa,
+ productImage: item.productImage ?? variant.productImage,
+ });
+ }
+
+ // 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: {
@@ -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>(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>(
- 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) {
diff --git a/package.json b/package.json
index e96e984..f1d6ea7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs
new file mode 100644
index 0000000..59d742b
--- /dev/null
+++ b/scripts/generate-sitemap-config.mjs
@@ -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();