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 | | `/about` | About us |
| `/contact` | Contact us | | `/contact` | Contact us |
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button | | `/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) | | `/api/search?q=` | Instant product search (`store-items?name=`, min 3 chars) |
## Homepage order ## 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) ## 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` — قطعات یخچال 2. `refrigrator-parts` — قطعات یخچال
3. `washing-machine-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 ## 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. `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) ## Original business data (do not invent replacements)
**About:** 30+ years. Sells home appliances, spare parts, and repairs small appliances. Wholesale and retail. **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: [] }); return Response.json({ items: [] });
} }
const encoded = encodeURIComponent(query);
const base = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}`;
try { try {
const response = await fetch( const storeResponse = await fetch(`${base}/store-items?name=${encoded}&pageSize=8`, {
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/store-items?name=${encodeURIComponent(query)}&pageSize=8`, cache: "no-store",
{ cache: "no-store" }, });
); const storeData = (await storeResponse.json()) as { items?: unknown[] };
return Response.json(await response.json()); 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 { } catch {
return Response.json({ items: [] }); 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)} /> <PageHero kicker="Blog" title={apiPost.title} image={blogImage(apiPost)} />
<section className="py-16 sm:py-24"> <section className="py-16 sm:py-24">
<Container className="max-w-3xl"> <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)} {formatFaDate(apiPost.publishedAt || apiPost.createdAt)}
</p> </p>
{apiPost.abstract ? <p className="mt-6 text-[16px] leading-9 text-muted">{apiPost.abstract}</p> : null} {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} /> <PageHero kicker="Blog" title={post.title} image={post.image} />
<section className="py-16 sm:py-24"> <section className="py-16 sm:py-24">
<Container className="max-w-3xl"> <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"> <div className="mt-8 space-y-5 text-[16px] leading-9 text-muted">
{post.body.map((paragraph) => ( {post.body.map((paragraph) => (
<p key={paragraph}>{paragraph}</p> <p key={paragraph}>{paragraph}</p>
+17 -9
View File
@@ -29,22 +29,26 @@ export default async function BlogPage({
return ( 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"> <section className="py-16 sm:py-24">
<Container> <Container>
{usingApi ? ( {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) => ( {result.items.map((post) => (
<Link key={post.id} href={blogHref(post)} className="group block"> <Link
<div className="overflow-hidden rounded-2xl"> 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 <Placeholder
src={blogImage(post)} src={blogImage(post)}
alt={post.title} alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/> />
</div> </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)} {formatFaDate(post.publishedAt || post.createdAt)}
</p> </p>
<h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2> <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} ) : 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) => ( {blogPosts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`} className="group block"> <Link
<div className="overflow-hidden rounded-2xl"> 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 <Placeholder
src={post.image} src={post.image}
alt={post.title} alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/> />
</div> </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> <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> <p className="mt-2 line-clamp-2 text-[14px] leading-7 text-muted">{post.excerpt}</p>
</Link> </Link>
+2
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header";
import { TopBar } from "@/components/layout/TopBar"; import { TopBar } from "@/components/layout/TopBar";
import { getNavCategories } from "@/lib/categories"; import { getNavCategories } from "@/lib/categories";
import { websiteDomain } from "@/lib/config";
import { site } from "@/lib/data"; import { site } from "@/lib/data";
const vazir = Vazirmatn({ const vazir = Vazirmatn({
@@ -22,6 +23,7 @@ const montserrat = Montserrat({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(`https://${websiteDomain}`),
title: { title: {
default: `${site.name} | ${site.tagline}`, default: `${site.name} | ${site.tagline}`,
template: `%s | ${site.name}`, 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 { AboutPreview } from "@/components/home/AboutPreview";
import { BannerRow } from "@/components/home/BannerRow"; import { BannerRow } from "@/components/home/BannerRow";
import { BlogCarousel } from "@/components/home/BlogCarousel"; import { BlogCarousel } from "@/components/home/BlogCarousel";
@@ -17,6 +18,7 @@ import {
refrigeratorPartsFallback, refrigeratorPartsFallback,
services, services,
showcaseCategories, showcaseCategories,
site,
slides, slides,
threeBannerRow, threeBannerRow,
twoBannerRow, twoBannerRow,
@@ -28,6 +30,13 @@ import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static
export const revalidate = 60; export const revalidate = 60;
export const metadata: Metadata = {
title: {
absolute: `${site.name} | ${site.tagline}`,
},
description: site.description,
};
export default async function HomePage() { export default async function HomePage() {
const [slots, consumables, refrigerator, washing, blogs] = await Promise.all([ const [slots, consumables, refrigerator, washing, blogs] = await Promise.all([
fetchStaticImageSlots("home"), fetchStaticImageSlots("home"),
@@ -141,20 +150,20 @@ export default async function HomePage() {
subtitle="قطعات مصرفی پرفروش برای تعمیرگاه و مصرف‌کننده" subtitle="قطعات مصرفی پرفروش برای تعمیرگاه و مصرف‌کننده"
products={consumables.products.length ? consumables.products : consumablesFallback} products={consumables.products.length ? consumables.products : consumablesFallback}
/> />
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
<ProductCarousel <ProductCarousel
id="refrigerator-parts" id="refrigerator-parts"
title={refrigerator.special?.title || "قطعات یخچال"} title={refrigerator.special?.title || "قطعات یخچال"}
subtitle="قطعات یخچال و فریزر" subtitle="قطعات یخچال و فریزر"
products={refrigerator.products.length ? refrigerator.products : refrigeratorPartsFallback} products={refrigerator.products.length ? refrigerator.products : refrigeratorPartsFallback}
/> />
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
<ProductCarousel <ProductCarousel
id="washing-machine-parts" id="washing-machine-parts"
title={washing.special?.title || "قطعات ماشین لباسشویی"} title={washing.special?.title || "قطعات ماشین لباسشویی"}
subtitle="قطعات لباسشویی خانگی و صنعتی" subtitle="قطعات لباسشویی خانگی و صنعتی"
products={washing.products.length ? washing.products : washingMachinePartsFallback} 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(0, 2)} columns={2} aspectRatio={fiveBanners.aspectRatio} />
<BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} /> <BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} />
<BlogCarousel posts={blogs.items} fallback={blogPosts} /> <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 { formatPrice } from "@/lib/format";
import { import {
brandName, brandName,
fetchCatalogProductBySlug,
fetchCatalogProductByTitle, fetchCatalogProductByTitle,
fetchStoreItemByProduct, fetchStoreItemByProduct,
fetchStoreVariant, fetchStoreVariant,
galleryUrls, galleryUrls,
variantTitle, variantTitle,
type CatalogProduct,
type StoreVariant,
} from "@/lib/store"; } from "@/lib/store";
export const revalidate = 60; 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({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { id } = await params; 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 { 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 }> }) { export default async function ProductDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const variant = await fetchStoreVariant(id); const { variant, catalog, productId } = await resolveProduct(id);
if (!variant) notFound(); if (!variant && !catalog) notFound();
const [catalog, storeItem, comments] = await Promise.all([ const comments = productId ? await fetchComments("product", productId) : [];
fetchCatalogProductByTitle(variant.productNameFa || variant.productTitle, variant.productId), const sibling =
variant.productId ? fetchStoreItemByProduct(variant.productId) : Promise.resolve({ storeItem: undefined }), productId && variant
variant.productId ? fetchComments("product", variant.productId) : Promise.resolve([]), ? await fetchStoreItemByProduct(productId)
]); : { storeItem: undefined };
const images = galleryUrls(catalog, variant.productImage); const images = galleryUrls(catalog, variant?.productImage || catalog?.image || catalog?.thumbnailUrl);
const price = variant.discountedPrice ?? variant.price; const price = variant?.discountedPrice ?? variant?.price ?? catalog?.store?.minPrice ?? null;
const oldPrice = variant.discountedPrice != null ? variant.price : null; const oldPrice = variant?.discountedPrice != null ? variant.price : null;
const title = catalog?.nameFa || variantTitle(variant); const title = catalog?.nameFa || catalog?.title || (variant ? variantTitle(variant) : "محصول");
const brand = brandName(catalog); const brand = brandName(catalog);
const variants = storeItem.storeItem?.variants ?? []; const variants = sibling.storeItem?.variants ?? [];
const inStock = variant.stockQuantity == null || variant.stockQuantity > 0; const inStock =
variant != null
? variant.stockQuantity == null || variant.stockQuantity > 0
: catalog?.store?.inStock !== false;
return ( return (
<section className="py-10 sm:py-14"> <section className="py-10 sm:py-14">
@@ -67,8 +130,11 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
<ProductGallery images={images} alt={title} /> <ProductGallery images={images} alt={title} />
<div> <div>
{brand ? <p className="text-[13px] font-medium text-brand">{brand}</p> : null} {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> <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> <p className="mt-2 text-[13px] text-muted">{variant.label}</p>
) : null} ) : null}
@@ -95,7 +161,7 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
<Link <Link
key={item.id} key={item.id}
href={`/products/${encodeURIComponent(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)} {item.label || variantTitle(item)}
</Link> </Link>
+63 -54
View File
@@ -1,10 +1,16 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Container } from "@/components/Container"; import { Container } from "@/components/Container";
import { PageHero } from "@/components/layout/PageHero";
import { ProductCard } from "@/components/ui/ProductCard"; import { ProductCard } from "@/components/ui/ProductCard";
import { fetchStoreItems, variantToProduct } from "@/lib/store";
import { getNavCategories } from "@/lib/categories"; import { getNavCategories } from "@/lib/categories";
import { faDigits } from "@/lib/format"; import { faDigits } from "@/lib/format";
import {
catalogProductToCard,
fetchCatalogProducts,
fetchStoreItems,
variantToProduct,
} from "@/lib/store";
export const revalidate = 60; export const revalidate = 60;
@@ -22,67 +28,70 @@ export default async function ProductsPage({
const page = Math.max(1, Number(params.page) || 1); const page = Math.max(1, Number(params.page) || 1);
const categoryId = params.categoryId; const categoryId = params.categoryId;
const q = params.q?.trim(); const q = params.q?.trim();
const [result, nav] = await Promise.all([ const name = q && q.length > 2 ? q : undefined;
fetchStoreItems({ page, pageSize: 20, categoryId, 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(), 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 const categoryTitle = nav
.flatMap((item) => [item, ...item.children.map((child) => ({ ...child, children: [] as never[] }))]) .flatMap((item) => [item, ...item.children.map((child) => ({ ...child, children: [] as never[] }))])
.find((item) => item.id === categoryId)?.title; .find((item) => item.id === categoryId)?.title;
const heroTitle = categoryTitle || (q ? `جستجو: ${q}` : "محصولات");
return ( return (
<section className="py-10 sm:py-14"> <>
<Container> <PageHero
<div className="mb-6"> kicker="Products"
<p className="text-[13px] text-muted"> title={heroTitle}
<Link href="/" className="hover:text-brand"> image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg"
خانه />
</Link> <section className="py-10 sm:py-14">
<span className="mx-2">/</span> <Container>
محصولات {total ? <p className="mb-6 text-[13px] text-muted">{faDigits(total)} کالا</p> : null}
</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>
{products.length === 0 ? ( {products.length === 0 ? (
<div className="rounded-2xl border border-line bg-white px-6 py-16 text-center"> <div className="rounded-2xl border border-line bg-white px-6 py-16 text-center">
<p className="text-[15px] font-bold text-ink">محصولی برای نمایش وجود ندارد</p> <p className="text-[15px] font-bold text-ink">محصولی برای نمایش وجود ندارد</p>
<p className="mt-2 text-[13px] text-muted">به‌زودی موجودی فروشگاه از پنل Meshkee اینجا دیده می‌شود.</p> <p className="mt-2 text-[13px] text-muted">به‌زودی موجودی فروشگاه از پنل Meshkee اینجا دیده می‌شود.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{products.map((product) => ( {products.map((product) => (
<ProductCard key={product.id} product={product} /> <ProductCard key={product.id} product={product} />
))} ))}
</div> </div>
)} )}
{pageCount > 1 ? ( {pageCount > 1 ? (
<div className="mt-8 flex justify-center gap-2"> <div className="mt-8 flex justify-center gap-2">
{Array.from({ length: pageCount }, (_, index) => { {Array.from({ length: pageCount }, (_, index) => {
const number = index + 1; const number = index + 1;
const query = new URLSearchParams(); const query = new URLSearchParams();
if (categoryId) query.set("categoryId", categoryId); if (categoryId) query.set("categoryId", categoryId);
if (q) query.set("q", q); if (q) query.set("q", q);
query.set("page", String(number)); query.set("page", String(number));
return ( return (
<Link <Link
key={number} key={number}
href={`/products?${query.toString()}`} href={`/products?${query.toString()}`}
className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`} className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`}
> >
{faDigits(number)} {faDigits(number)}
</Link> </Link>
); );
})} })}
</div> </div>
) : null} ) : null}
</Container> </Container>
</section> </section>
</>
); );
} }
+42 -36
View File
@@ -37,53 +37,56 @@ export function BlogCarousel({
return ( return (
<section id="blog" className="py-20 sm:py-24"> <section id="blog" className="py-20 sm:py-24">
<Container> <Container>
<div className="mb-10 flex items-end justify-between gap-4"> <div className="mb-10">
<div> <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> <span className="h-[3px] w-8 bg-brand" />
<span className="h-[3px] w-8 bg-brand" /> <p className="section-kicker">Blog</p>
<p className="section-kicker">Blog</p> </div>
<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)}
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
aria-label="قبلی"
>
<ChevronRight className="size-4" />
</button>
<button
type="button"
onClick={() => scrollByCard(-1)}
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
aria-label="بعدی"
>
<ChevronLeft className="size-4" />
</button>
<Link href="/blog" className="hidden rounded-xl bg-ink px-5 py-2.5 text-[13px] text-white hover:bg-brand sm:inline-flex">
مشاهده همه
</Link>
</div> </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">
<button
type="button"
onClick={() => scrollByCard(1)}
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
aria-label="قبلی"
>
<ChevronRight className="size-4" />
</button>
<button
type="button"
onClick={() => scrollByCard(-1)}
className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand"
aria-label="بعدی"
>
<ChevronLeft className="size-4" />
</button>
<Link href="/blog" className="hidden rounded-xl bg-ink px-5 py-2.5 text-[13px] text-white hover:bg-brand sm:inline-flex">
مشاهده همه
</Link>
</div> </div>
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
نکته‌هایی از تعمیرگاه و بورس قطعات — برای مصرف‌کننده و همکار.
</p>
</div> </div>
<div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto"> <div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto">
{usingApi {usingApi
? posts.map((post) => ( ? 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"> <Link href={blogHref(post)} className="group block">
<div className="overflow-hidden rounded-2xl"> <div className="overflow-hidden rounded-xl">
<Placeholder <Placeholder
src={blogImage(post)} src={blogImage(post)}
alt={post.title} alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/> />
</div> </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)} {formatFaDate(post.publishedAt || post.createdAt)}
</p> </p>
<h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3> <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> </article>
)) ))
: fallback.map((post) => ( : 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"> <Link href={`/blog/${post.slug}`} className="group block">
<div className="overflow-hidden rounded-2xl"> <div className="overflow-hidden rounded-xl">
<Placeholder <Placeholder
src={post.image} src={post.image}
alt={post.title} alt={post.title}
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
/> />
</div> </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> <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> <p className="mt-2 line-clamp-2 text-[13px] leading-7 text-muted">{post.excerpt}</p>
</Link> </Link>
+185 -121
View File
@@ -1,156 +1,220 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { Placeholder } from "@/components/Placeholder"; import { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink"; import { SlotLink } from "@/components/SlotLink";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images"; import type { SlotMediaItem } from "@/lib/static-images";
function circularOffset(index: number, active: number, length: number) { const SLIDE_W = "min(1280px, calc(100vw - 5rem))";
let diff = index - active; const GAP = "1rem";
if (diff > length / 2) diff -= length; const INTERVAL_MS = 6000;
if (diff < -length / 2) diff += length; const TRANSITION_MS = 700;
return diff;
}
const slideWidth = "min(1280px, calc(100vw - 5rem))"; export function Hero({ slides }: { slides: SlotMediaItem[]; aspectRatio?: string }) {
export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectRatio?: string }) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [shift, setShift] = useState(0);
const [animating, setAnimating] = useState(false);
const [paused, setPaused] = 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(() => { useEffect(() => {
setActive(0); setActive(0);
setShift(0);
shiftRef.current = 0;
setAnimating(false);
lock.current = false;
}, [slides.length]); }, [slides.length]);
useEffect(() => { useEffect(() => {
if (slides.length < 2 || paused) return; if (slides.length < 2 || paused || animating) return;
const timer = setInterval(() => { const timer = window.setInterval(() => go(1), INTERVAL_MS);
setActive((current) => (current + 1) % slides.length); return () => window.clearInterval(timer);
}, 6000); }, [paused, animating, slides.length, active]);
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 === 0) return null; 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 ( return (
<section className="relative overflow-x-clip bg-white pt-5 pb-8"> <section className="relative overflow-x-clip bg-white pt-5 pb-8">
<div <div
className={ratio ? "relative min-h-[340px]" : "relative h-[min(62vh,640px)] min-h-[340px]"} className="relative h-[min(62vh,640px)] min-h-[340px]"
style={ratio ? { aspectRatio: ratio, maxHeight: 640 } : undefined} style={{ ["--slide-w" as string]: SLIDE_W, ["--slide-gap" as string]: GAP }}
dir="ltr" dir="ltr"
onMouseEnter={() => setPaused(true)} onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)} onMouseLeave={() => setPaused(false)}
> >
{slides.map((item, i) => { <div
const offset = circularOffset(i, active, slides.length); className="absolute inset-0"
const isCenter = offset === 0; style={{
const isPeek = Math.abs(offset) === 1; 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 ( return (
<article <article
key={item.id} key={`slot-${offset}`}
className="absolute top-0 h-full w-[min(1280px,calc(100vw-5rem))]" className="absolute top-0 h-full"
style={{ style={{
left: `calc(50% - (${slideWidth}) / 2 + ${offset} * ((${slideWidth}) + 1rem))`, width: "var(--slide-w)",
zIndex: isCenter ? 3 : isPeek ? 2 : 1, left: `calc(50% - var(--slide-w) / 2 + ${offset} * (var(--slide-w) + var(--slide-gap)))`,
opacity: isCenter || isPeek ? 1 : 0, zIndex: isActiveSlot ? 3 : 2,
pointerEvents: isCenter || isPeek ? "auto" : "none", }}
transition: "left 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease", >
}} {isActiveSlot ? (
> <SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl">
{isCenter ? ( <Placeholder
<SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl"> src={item.image}
<Placeholder fallbackSrc={item.fallbackSrc}
src={item.image} alt={item.title ?? ""}
fallbackSrc={item.fallbackSrc} className="h-full w-full object-cover"
alt={item.title ?? ""} />
className="h-full w-full object-cover" <div
/> className="absolute inset-x-4 bottom-4 z-10 rounded-2xl px-5 py-3.5 sm:inset-x-5 sm:bottom-5 sm:px-7 sm:py-4"
<div style={{
className="absolute inset-x-4 bottom-4 z-10 rounded-2xl px-5 py-3.5 sm:inset-x-5 sm:bottom-5 sm:px-7 sm:py-4" background:
style={{ "linear-gradient(180deg, rgb(48 52 56 / 0.72) 0%, rgb(18 20 22 / 0.82) 100%)",
background: backdropFilter: "blur(22px) saturate(180%)",
"linear-gradient(180deg, rgb(48 52 56 / 0.72) 0%, rgb(18 20 22 / 0.82) 100%)", WebkitBackdropFilter: "blur(22px) saturate(180%)",
backdropFilter: "blur(22px) saturate(180%)", border: "1px solid rgb(255 255 255 / 0.22)",
WebkitBackdropFilter: "blur(22px) saturate(180%)", boxShadow:
border: "1px solid rgb(255 255 255 / 0.22)", "inset 0 1px 0 rgb(255 255 255 / 0.28), 0 10px 28px rgb(0 0 0 / 0.28)",
boxShadow: }}
"inset 0 1px 0 rgb(255 255 255 / 0.28), 0 10px 28px rgb(0 0 0 / 0.28)", >
}} <div dir="rtl" className="flex items-center justify-between gap-4">
> <div className="flex min-w-0 items-center gap-3 sm:gap-5">
<div dir="rtl" className="flex items-center justify-between gap-4"> <h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]">
<div className="flex min-w-0 items-center gap-3 sm:gap-5"> {item.title || "فروشگاه برادران برقعی"}
<h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]"> </h1>
{item.title} <span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" />
</h1> <span className="hidden h-6 w-px shrink-0 bg-white/20 sm:block" />
<span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" /> {item.kicker ? (
<span className="hidden h-6 w-px shrink-0 bg-white/20 sm:block" /> <p className="font-latin hidden shrink-0 text-[11px] tracking-[0.2em] text-white/60 uppercase sm:block">
{item.kicker ? ( {item.kicker}
<p className="font-latin hidden shrink-0 text-[11px] tracking-[0.2em] text-white/60 uppercase sm:block"> </p>
{item.kicker} ) : null}
</p> </div>
) : null} <span className="inline-flex shrink-0 rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white sm:px-6 sm:py-3 sm:text-[14px]">
مشاهده قطعات
</span>
</div> </div>
<span className="inline-flex shrink-0 rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white sm:px-6 sm:py-3 sm:text-[14px]">
مشاهده قطعات
</span>
</div> </div>
</div> </SlotLink>
</SlotLink> ) : (
) : ( <button
<button type="button"
type="button" className="relative block h-full w-full overflow-hidden rounded-2xl"
className="relative block h-full w-full overflow-hidden rounded-2xl" onClick={() => go(offset < shift ? -1 : 1)}
onClick={() => setActive(i)} aria-label={item.title}
aria-label={item.title} disabled={animating}
> >
<Placeholder src={item.image} fallbackSrc={item.fallbackSrc} alt="" className="h-full w-full object-cover" /> <Placeholder
<span className="absolute inset-0 bg-white/35" /> src={item.image}
</button> fallbackSrc={item.fallbackSrc}
)} alt={item.title ?? ""}
</article> className="h-full w-full object-cover"
); />
})} <span className="absolute inset-0 bg-white/35" />
</button>
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-[min(1280px,calc(100vw-5rem))] -translate-x-1/2"> )}
<button </article>
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"
aria-label="اسلاید راست"
>
<ChevronRight className="size-5" />
</button>
<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"
aria-label="اسلاید چپ"
>
<ChevronLeft className="size-5" />
</button>
</div> </div>
{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)}
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" />
</button>
<button
type="button"
onClick={() => 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="اسلاید چپ"
>
<ChevronLeft className="size-5" />
</button>
</div>
) : null}
</div> </div>
<div className="relative z-10 mt-5 flex justify-center gap-2"> {count > 1 ? (
{slides.map((item, i) => ( <div className="relative z-10 mt-5 flex justify-center gap-2">
<button {slides.map((item, i) => (
key={item.id} <button
type="button" key={item.id}
onClick={() => setActive(i)} type="button"
className={`h-1.5 rounded-full transition-all ${i === active ? "w-8 bg-brand" : "w-3 bg-ink/20"}`} disabled={animating}
aria-label={`اسلاید ${i + 1}`} onClick={() => {
/> if (i === active || lock.current) return;
))} const forward = (i - active + count) % count;
</div> 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> </section>
); );
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export function Services({ items }: { items: SlotMediaItem[] }) {
<Placeholder <Placeholder
src={service.image} src={service.image}
fallbackSrc={service.fallbackSrc} fallbackSrc={service.fallbackSrc}
alt="" alt={service.title ?? ""}
className="mt-1 size-16 shrink-0 object-contain sm:size-[4.5rem]" 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 { Placeholder } from "@/components/Placeholder";
import { SlotLink } from "@/components/SlotLink"; import { SlotLink } from "@/components/SlotLink";
import { glossyBar } from "@/lib/glossy"; import { glossyBar } from "@/lib/glossy";
import { parseAspectRatio } from "@/lib/static-images";
import type { SlotMediaItem } from "@/lib/static-images"; import type { SlotMediaItem } from "@/lib/static-images";
const fallbackCopy = { const fallbackCopy = {
@@ -11,37 +10,32 @@ const fallbackCopy = {
href: "/contact", 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 src = image?.image ?? "/images/banners/wide.jpg";
const href = image?.href || fallbackCopy.href; const href = image?.href || fallbackCopy.href;
const ratio = parseAspectRatio(aspectRatio);
return ( return (
<section <section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]">
className={`relative overflow-hidden ${ratio ? "min-h-[280px]" : "h-[280px] sm:h-[360px]"}`}
style={ratio ? { aspectRatio: ratio } : undefined}
>
<SlotLink href={href} className="absolute inset-0"> <SlotLink href={href} className="absolute inset-0">
<Placeholder <Placeholder
src={src} src={src}
fallbackSrc={image?.fallbackSrc ?? "/images/banners/wide.jpg"} fallbackSrc={image?.fallbackSrc ?? "/images/banners/wide.jpg"}
alt="" alt={image?.title || fallbackCopy.title}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<span className="absolute inset-0 bg-ink/20" /> <span className="absolute inset-0 bg-ink/20" />
</SlotLink> </SlotLink>
<Container className="relative flex h-full items-center"> <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"> <p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">
{image?.kicker || fallbackCopy.kicker} {image?.kicker || fallbackCopy.kicker}
</p> </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} {image?.title || fallbackCopy.title}
</p> </p>
{image?.text ? <p className="mt-2 text-[14px] leading-7 text-white/75">{image.text}</p> : null}
<SlotLink <SlotLink
href={href} 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> </SlotLink>
+10 -12
View File
@@ -1,6 +1,6 @@
import { GlassBox } from "@/components/GlassBox";
import { Placeholder } from "@/components/Placeholder"; import { Placeholder } from "@/components/Placeholder";
import { Container } from "@/components/Container"; import { Container } from "@/components/Container";
import { glossyBar } from "@/lib/glossy";
export function PageHero({ export function PageHero({
kicker, kicker,
@@ -12,17 +12,15 @@ export function PageHero({
image: string; image: string;
}) { }) {
return ( return (
<section className="relative h-[42vh] min-h-[280px] overflow-hidden bg-ink"> <section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]">
<Placeholder src={image} alt="" className="absolute inset-0 h-full w-full object-cover" /> <Placeholder src={image} alt={title} 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-0 bg-ink/75" />
<div className="absolute inset-x-0 bottom-0"> <Container className="relative flex h-full items-center px-4 sm:px-6">
<Container className="px-4 sm:px-6"> <div className="max-w-xl rounded-2xl px-6 py-4 sm:px-8 sm:py-5" style={glossyBar}>
<GlassBox frostSrc={image} className="relative mb-6 inline-block max-w-lg px-8 py-7"> <p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">{kicker}</p>
<p className="section-kicker">{kicker}</p> <h1 className="mt-1.5 line-clamp-2 text-[22px] font-extrabold leading-snug text-white sm:text-[28px]">{title}</h1>
<h1 className="mt-2 text-3xl font-extrabold sm:text-5xl">{title}</h1> </div>
</GlassBox> </Container>
</Container>
</div>
</section> </section>
); );
} }
+4 -4
View File
@@ -17,7 +17,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
<div> <div>
<button <button
type="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)} onClick={() => setLightbox(true)}
onMouseEnter={() => setZoomed(true)} onMouseEnter={() => setZoomed(true)}
onMouseLeave={() => { onMouseLeave={() => {
@@ -35,10 +35,10 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
src={current} src={current}
fallbackSrc="/placeholders/product.svg" fallbackSrc="/placeholders/product.svg"
alt={alt} 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 <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={{ style={{
backgroundImage: `url(${current})`, backgroundImage: `url(${current})`,
transform: zoomed ? "scale(1.85)" : "scale(1)", transform: zoomed ? "scale(1.85)" : "scale(1)",
@@ -56,7 +56,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
onClick={() => setActive(index)} onClick={() => setActive(index)}
className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-brand" : "border-line"}`} 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> </button>
))} ))}
</div> </div>
+3 -2
View File
@@ -9,7 +9,7 @@ export function ProductCard({ product }: { product: Product }) {
return ( return (
<article className="group flex h-full flex-col rounded-2xl border border-line bg-white p-3"> <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 ? ( {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"> <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)}٪ {faDigits(product.discount)}٪
@@ -18,7 +18,8 @@ export function ProductCard({ product }: { product: Product }) {
<Placeholder <Placeholder
src={product.image} src={product.image}
fallbackSrc="/placeholders/product.svg" 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> </Link>
{product.brand ? ( {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", label: "Full-row contact banner",
kind: "single", kind: "single",
pageKey: "home", pageKey: "home",
aspectRatio: "32:9", aspectRatio: "64:9",
itemCount: 1, itemCount: 1,
recommendedWidth: 1920, recommendedWidth: 1920,
}, },
+102 -17
View File
@@ -30,6 +30,8 @@ export type StoreSpecialProduct = {
productTitle?: string | null; productTitle?: string | null;
productNameFa?: string | null; productNameFa?: string | null;
productImage?: string | null; productImage?: string | null;
isActive?: boolean;
sortOrder?: number | null;
variants?: StoreVariant[]; variants?: StoreVariant[];
}; };
@@ -48,6 +50,12 @@ export type CatalogProduct = {
categoryNameFa?: string | null; categoryNameFa?: string | null;
categoryName?: string | null; categoryName?: string | null;
commentCount?: number; commentCount?: number;
store?: {
variantCount?: number | null;
minPrice?: number | null;
maxPrice?: number | null;
inStock?: boolean | null;
} | null;
}; };
export type Paged<T> = { export type Paged<T> = {
@@ -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]; const variant = item.variants?.[0];
if (!variant) return null; if (variant) {
return variantToProduct({ return variantToProduct({
...variant, ...variant,
productId: item.productId ?? variant.productId, productId: item.productId ?? variant.productId,
productTitle: item.productTitle ?? variant.productTitle, productTitle: item.productTitle ?? variant.productTitle,
productNameFa: item.productNameFa ?? variant.productNameFa, productNameFa: item.productNameFa ?? variant.productNameFa,
productImage: item.productImage ?? variant.productImage, 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: { export async function fetchStoreItems(params: {
@@ -144,14 +173,33 @@ export async function fetchStoreItemByProduct(productId: string) {
} }
export async function fetchStoreSpecials() { 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 ?? []; return data.items ?? [];
} }
export async function fetchSpecialProducts(key: string) { export async function fetchSpecialProducts(key: string) {
const specials = await fetchStoreSpecials(); const specials = await fetchStoreSpecials();
const special = specials.find((item) => item.key === key); 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 }; return { special, products };
} }
@@ -167,22 +215,59 @@ export function galleryUrls(product?: CatalogProduct | null, fallback?: string |
return urls.length ? urls : [PLACEHOLDER]; 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) { 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>>( const data = await meshkeeGet<Paged<CatalogProduct>>(
tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=8`), tenantPath(`/products?name=${encodeURIComponent(query)}&pageSize=12`),
{ items: [], total: 0, page: 1, pageSize: 8 }, { items: [], total: 0, page: 1, pageSize: 12 },
); );
const match = const match =
data.items.find((item) => String(item.id) === String(productId)) ?? data.items.find((item) => String(item.id) === String(productId)) ??
data.items[0] ?? data.items[0] ??
null; null;
if (!match?.slug) return match; if (!match?.slug) return match;
const detail = await meshkeeGet<{ product?: CatalogProduct }>( return fetchCatalogProductBySlug(match.slug);
tenantPath(`/products/${encodeURIComponent(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) { export function brandName(product?: CatalogProduct | null) {
+3 -1
View File
@@ -3,10 +3,12 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^0.539.0", "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();