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:
co-authored by
Cursor
parent
11d0ba64ed
commit
24b5649abd
@@ -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
@@ -48,11 +48,12 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the
|
||||
| `/about` | About us |
|
||||
| `/contact` | Contact us |
|
||||
| `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button |
|
||||
| `/meshkee/sitemap-config.json` | Build-time route manifest for Meshkee sitemap sync |
|
||||
| `/api/search?q=` | Instant product search (`store-items?name=`, min 3 chars) |
|
||||
|
||||
## Homepage order
|
||||
|
||||
`Hero` → `Services` → three product carousels → `CategoryShowcase` → `AboutPreview` → five-banner (2+3) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`.
|
||||
`Hero` → `Services` → اقلام مصرفی → `CategoryShowcase` → قطعات یخچال → `AboutPreview` → قطعات ماشین لباسشویی → five-banner (2+3) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`.
|
||||
|
||||
## Homepage carousels (store specials)
|
||||
|
||||
@@ -62,7 +63,9 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the
|
||||
2. `refrigrator-parts` — قطعات یخچال
|
||||
3. `washing-machine-parts` — قطعات ماشین لباسشویی
|
||||
|
||||
If a special has no `items`, keep sample products from `lib/data.ts`. Use `variants[0].id` as the product URL.
|
||||
BRB specials use `source: "product"` (items may have empty `variants[]`). Map product fields directly; resolve slug via catalog for detail links. If a special has no `items`, keep sample products from `lib/data.ts`.
|
||||
|
||||
Products list/search: prefer store-items; if empty, fall back to `GET /products` catalog.
|
||||
|
||||
## Catalog
|
||||
|
||||
@@ -89,6 +92,14 @@ If a special has no `items`, keep sample products from `lib/data.ts`. Use `varia
|
||||
|
||||
`url` → img, `linkUrl` → wrap in a link, empty/fail → placeholder. Use `slot.aspectRatio`. If a slot’s size/ratio changes in CSS, update `STATIC_IMAGE_SLOTS` in the same change.
|
||||
|
||||
## SEO
|
||||
|
||||
- Do **not** ship `public/sitemap.xml` or `public/robots.txt` (nginx proxies to Meshkee API).
|
||||
- Build generates `lib/sitemap-config.generated.json` (`npm run generate:sitemap-config` / `prebuild`); served at `GET /meshkee/sitemap-config.json`.
|
||||
- After deploy: business dashboard → Website → Settings → Sync sitemap config.
|
||||
- Templates omitted (defaults `/products/{slug}`, `/blog/{slug}` match this site).
|
||||
- Every public page has unique title/description; product/blog prefer CMS fields.
|
||||
|
||||
## Original business data (do not invent replacements)
|
||||
|
||||
**About:** 30+ years. Sells home appliances, spare parts, and repairs small appliances. Wholesale and retail.
|
||||
|
||||
+36
-5
@@ -7,12 +7,43 @@ export async function GET(request: Request) {
|
||||
return Response.json({ items: [] });
|
||||
}
|
||||
|
||||
const encoded = encodeURIComponent(query);
|
||||
const base = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/store-items?name=${encodeURIComponent(query)}&pageSize=8`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
return Response.json(await response.json());
|
||||
const storeResponse = await fetch(`${base}/store-items?name=${encoded}&pageSize=8`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const storeData = (await storeResponse.json()) as { items?: unknown[] };
|
||||
if (storeData.items?.length) {
|
||||
return Response.json(storeData);
|
||||
}
|
||||
|
||||
const catalogResponse = await fetch(`${base}/products?name=${encoded}&pageSize=8`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const catalogData = (await catalogResponse.json()) as {
|
||||
items?: Array<{
|
||||
id: string;
|
||||
nameFa?: string | null;
|
||||
title?: string | null;
|
||||
slug?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
image?: string | null;
|
||||
store?: { minPrice?: number | null; maxPrice?: number | null };
|
||||
}>;
|
||||
};
|
||||
|
||||
return Response.json({
|
||||
items: (catalogData.items ?? []).map((item) => ({
|
||||
id: item.slug || item.id,
|
||||
productId: item.id,
|
||||
productNameFa: item.nameFa,
|
||||
productTitle: item.title,
|
||||
productImage: item.thumbnailUrl || item.image,
|
||||
price: item.store?.minPrice ?? item.store?.maxPrice ?? null,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
return Response.json({ items: [] });
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default async function BlogPostPage({
|
||||
<PageHero kicker="Blog" title={apiPost.title} image={blogImage(apiPost)} />
|
||||
<section className="py-16 sm:py-24">
|
||||
<Container className="max-w-3xl">
|
||||
<p className="font-latin text-[12px] tracking-[0.18em] text-muted">
|
||||
<p className="text-[13px] text-muted">
|
||||
{formatFaDate(apiPost.publishedAt || apiPost.createdAt)}
|
||||
</p>
|
||||
{apiPost.abstract ? <p className="mt-6 text-[16px] leading-9 text-muted">{apiPost.abstract}</p> : null}
|
||||
@@ -69,7 +69,7 @@ export default async function BlogPostPage({
|
||||
<PageHero kicker="Blog" title={post.title} image={post.image} />
|
||||
<section className="py-16 sm:py-24">
|
||||
<Container className="max-w-3xl">
|
||||
<p className="font-latin text-[12px] tracking-[0.18em] text-muted">{post.date}</p>
|
||||
<p className="text-[13px] text-muted">{post.date}</p>
|
||||
<div className="mt-8 space-y-5 text-[16px] leading-9 text-muted">
|
||||
{post.body.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
|
||||
+17
-9
@@ -29,22 +29,26 @@ export default async function BlogPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero kicker="Blog" title="مقالات" image="/images/slides/slide-2.jpg" />
|
||||
<PageHero kicker="Blog" title="مقالات" image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg" />
|
||||
<section className="py-16 sm:py-24">
|
||||
<Container>
|
||||
{usingApi ? (
|
||||
<>
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{result.items.map((post) => (
|
||||
<Link key={post.id} href={blogHref(post)} className="group block">
|
||||
<div className="overflow-hidden rounded-2xl">
|
||||
<Link
|
||||
key={post.id}
|
||||
href={blogHref(post)}
|
||||
className="group block rounded-2xl border border-line bg-white p-3"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl">
|
||||
<Placeholder
|
||||
src={blogImage(post)}
|
||||
alt={post.title}
|
||||
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">
|
||||
<p className="mt-3 text-[12px] text-muted">
|
||||
{formatFaDate(post.publishedAt || post.createdAt)}
|
||||
</p>
|
||||
<h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2>
|
||||
@@ -72,17 +76,21 @@ export default async function BlogPage({
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{blogPosts.map((post) => (
|
||||
<Link key={post.slug} href={`/blog/${post.slug}`} className="group block">
|
||||
<div className="overflow-hidden rounded-2xl">
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group block rounded-2xl border border-line bg-white p-3"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl">
|
||||
<Placeholder
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p>
|
||||
<p className="mt-3 text-[12px] text-muted">{post.date}</p>
|
||||
<h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2>
|
||||
<p className="mt-2 line-clamp-2 text-[14px] leading-7 text-muted">{post.excerpt}</p>
|
||||
</Link>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AboutPreview } from "@/components/home/AboutPreview";
|
||||
import { BannerRow } from "@/components/home/BannerRow";
|
||||
import { BlogCarousel } from "@/components/home/BlogCarousel";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
refrigeratorPartsFallback,
|
||||
services,
|
||||
showcaseCategories,
|
||||
site,
|
||||
slides,
|
||||
threeBannerRow,
|
||||
twoBannerRow,
|
||||
@@ -28,6 +30,13 @@ import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: `${site.name} | ${site.tagline}`,
|
||||
},
|
||||
description: site.description,
|
||||
};
|
||||
|
||||
export default async function HomePage() {
|
||||
const [slots, consumables, refrigerator, washing, blogs] = await Promise.all([
|
||||
fetchStaticImageSlots("home"),
|
||||
@@ -141,20 +150,20 @@ export default async function HomePage() {
|
||||
subtitle="قطعات مصرفی پرفروش برای تعمیرگاه و مصرفکننده"
|
||||
products={consumables.products.length ? consumables.products : consumablesFallback}
|
||||
/>
|
||||
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
|
||||
<ProductCarousel
|
||||
id="refrigerator-parts"
|
||||
title={refrigerator.special?.title || "قطعات یخچال"}
|
||||
subtitle="قطعات یخچال و فریزر"
|
||||
products={refrigerator.products.length ? refrigerator.products : refrigeratorPartsFallback}
|
||||
/>
|
||||
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
|
||||
<ProductCarousel
|
||||
id="washing-machine-parts"
|
||||
title={washing.special?.title || "قطعات ماشین لباسشویی"}
|
||||
subtitle="قطعات لباسشویی خانگی و صنعتی"
|
||||
products={washing.products.length ? washing.products : washingMachinePartsFallback}
|
||||
/>
|
||||
<CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} />
|
||||
<AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} />
|
||||
<BannerRow banners={fiveBanners.items.slice(0, 2)} columns={2} aspectRatio={fiveBanners.aspectRatio} />
|
||||
<BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} />
|
||||
<BlogCarousel posts={blogs.items} fallback={blogPosts} />
|
||||
|
||||
+83
-17
@@ -8,45 +8,108 @@ import { fetchComments } from "@/lib/comments";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import {
|
||||
brandName,
|
||||
fetchCatalogProductBySlug,
|
||||
fetchCatalogProductByTitle,
|
||||
fetchStoreItemByProduct,
|
||||
fetchStoreVariant,
|
||||
galleryUrls,
|
||||
variantTitle,
|
||||
type CatalogProduct,
|
||||
type StoreVariant,
|
||||
} from "@/lib/store";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
async function resolveProduct(id: string): Promise<{
|
||||
variant: StoreVariant | null;
|
||||
catalog: CatalogProduct | null;
|
||||
productId?: string;
|
||||
}> {
|
||||
const variant = await fetchStoreVariant(id);
|
||||
if (variant) {
|
||||
const catalog = await fetchCatalogProductByTitle(
|
||||
variant.productNameFa || variant.productTitle,
|
||||
variant.productId,
|
||||
);
|
||||
return { variant, catalog, productId: variant.productId };
|
||||
}
|
||||
|
||||
const bySlug = await fetchCatalogProductBySlug(id);
|
||||
if (bySlug) {
|
||||
return { variant: null, catalog: bySlug, productId: bySlug.id };
|
||||
}
|
||||
|
||||
const byProduct = await fetchStoreItemByProduct(id);
|
||||
const firstVariant = byProduct.storeItem?.variants?.[0];
|
||||
if (firstVariant) {
|
||||
const catalog = await fetchCatalogProductByTitle(
|
||||
firstVariant.productNameFa || firstVariant.productTitle,
|
||||
id,
|
||||
);
|
||||
return {
|
||||
variant: {
|
||||
...firstVariant,
|
||||
productId: id,
|
||||
},
|
||||
catalog,
|
||||
productId: id,
|
||||
};
|
||||
}
|
||||
|
||||
return { variant: null, catalog: null };
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const variant = await fetchStoreVariant(id);
|
||||
const resolved = await resolveProduct(id);
|
||||
const title =
|
||||
resolved.catalog?.nameFa ||
|
||||
resolved.catalog?.title ||
|
||||
(resolved.variant ? variantTitle(resolved.variant) : null);
|
||||
const description =
|
||||
resolved.catalog?.summary?.trim() ||
|
||||
stripHtml(resolved.catalog?.descriptionHtml) ||
|
||||
undefined;
|
||||
return {
|
||||
title: variant ? variantTitle(variant) : "محصول",
|
||||
title: title || "محصول",
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
function stripHtml(html?: string | null) {
|
||||
if (!html?.trim()) return undefined;
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.slice(0, 180) || undefined;
|
||||
}
|
||||
|
||||
export default async function ProductDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const variant = await fetchStoreVariant(id);
|
||||
if (!variant) notFound();
|
||||
const { variant, catalog, productId } = await resolveProduct(id);
|
||||
if (!variant && !catalog) notFound();
|
||||
|
||||
const [catalog, storeItem, comments] = await Promise.all([
|
||||
fetchCatalogProductByTitle(variant.productNameFa || variant.productTitle, variant.productId),
|
||||
variant.productId ? fetchStoreItemByProduct(variant.productId) : Promise.resolve({ storeItem: undefined }),
|
||||
variant.productId ? fetchComments("product", variant.productId) : Promise.resolve([]),
|
||||
]);
|
||||
const comments = productId ? await fetchComments("product", productId) : [];
|
||||
const sibling =
|
||||
productId && variant
|
||||
? await fetchStoreItemByProduct(productId)
|
||||
: { storeItem: undefined };
|
||||
|
||||
const images = galleryUrls(catalog, variant.productImage);
|
||||
const price = variant.discountedPrice ?? variant.price;
|
||||
const oldPrice = variant.discountedPrice != null ? variant.price : null;
|
||||
const title = catalog?.nameFa || variantTitle(variant);
|
||||
const images = galleryUrls(catalog, variant?.productImage || catalog?.image || catalog?.thumbnailUrl);
|
||||
const price = variant?.discountedPrice ?? variant?.price ?? catalog?.store?.minPrice ?? null;
|
||||
const oldPrice = variant?.discountedPrice != null ? variant.price : null;
|
||||
const title = catalog?.nameFa || catalog?.title || (variant ? variantTitle(variant) : "محصول");
|
||||
const brand = brandName(catalog);
|
||||
const variants = storeItem.storeItem?.variants ?? [];
|
||||
const inStock = variant.stockQuantity == null || variant.stockQuantity > 0;
|
||||
const variants = sibling.storeItem?.variants ?? [];
|
||||
const inStock =
|
||||
variant != null
|
||||
? variant.stockQuantity == null || variant.stockQuantity > 0
|
||||
: catalog?.store?.inStock !== false;
|
||||
|
||||
return (
|
||||
<section className="py-10 sm:py-14">
|
||||
@@ -67,8 +130,11 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
|
||||
<ProductGallery images={images} alt={title} />
|
||||
<div>
|
||||
{brand ? <p className="text-[13px] font-medium text-brand">{brand}</p> : null}
|
||||
{catalog?.categoryNameFa ? (
|
||||
<p className="mt-1 text-[12px] text-muted">{catalog.categoryNameFa}</p>
|
||||
) : null}
|
||||
<h1 className="mt-2 text-[22px] font-extrabold leading-9 text-ink">{title}</h1>
|
||||
{variant.label && variant.label !== title ? (
|
||||
{variant?.label && variant.label !== title ? (
|
||||
<p className="mt-2 text-[13px] text-muted">{variant.label}</p>
|
||||
) : null}
|
||||
|
||||
@@ -95,7 +161,7 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/products/${encodeURIComponent(item.id)}`}
|
||||
className={`rounded-lg border px-3 py-1.5 text-[12px] ${item.id === variant.id ? "border-brand bg-paper text-ink" : "border-line bg-white text-muted"}`}
|
||||
className={`rounded-lg border px-3 py-1.5 text-[12px] ${item.id === variant?.id ? "border-brand bg-paper text-ink" : "border-line bg-white text-muted"}`}
|
||||
>
|
||||
{item.label || variantTitle(item)}
|
||||
</Link>
|
||||
|
||||
+27
-18
@@ -1,10 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/Container";
|
||||
import { PageHero } from "@/components/layout/PageHero";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { fetchStoreItems, variantToProduct } from "@/lib/store";
|
||||
import { getNavCategories } from "@/lib/categories";
|
||||
import { faDigits } from "@/lib/format";
|
||||
import {
|
||||
catalogProductToCard,
|
||||
fetchCatalogProducts,
|
||||
fetchStoreItems,
|
||||
variantToProduct,
|
||||
} from "@/lib/store";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -22,32 +28,34 @@ export default async function ProductsPage({
|
||||
const page = Math.max(1, Number(params.page) || 1);
|
||||
const categoryId = params.categoryId;
|
||||
const q = params.q?.trim();
|
||||
const [result, nav] = await Promise.all([
|
||||
fetchStoreItems({ page, pageSize: 20, categoryId, name: q && q.length > 2 ? q : undefined }),
|
||||
const name = q && q.length > 2 ? q : undefined;
|
||||
|
||||
const [storeResult, catalogResult, nav] = await Promise.all([
|
||||
fetchStoreItems({ page, pageSize: 20, categoryId, name }),
|
||||
fetchCatalogProducts({ page, pageSize: 20, categoryId, name }),
|
||||
getNavCategories(),
|
||||
]);
|
||||
const products = result.items.map(variantToProduct);
|
||||
const pageCount = Math.max(1, Math.ceil((result.total || products.length) / (result.pageSize || 20)));
|
||||
|
||||
const fromStore = storeResult.items.map(variantToProduct);
|
||||
const products = fromStore.length ? fromStore : catalogResult.items.map(catalogProductToCard);
|
||||
const total = fromStore.length ? storeResult.total : catalogResult.total;
|
||||
const pageSize = fromStore.length ? storeResult.pageSize : catalogResult.pageSize;
|
||||
const pageCount = Math.max(1, Math.ceil((total || products.length) / (pageSize || 20)));
|
||||
const categoryTitle = nav
|
||||
.flatMap((item) => [item, ...item.children.map((child) => ({ ...child, children: [] as never[] }))])
|
||||
.find((item) => item.id === categoryId)?.title;
|
||||
const heroTitle = categoryTitle || (q ? `جستجو: ${q}` : "محصولات");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
kicker="Products"
|
||||
title={heroTitle}
|
||||
image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg"
|
||||
/>
|
||||
<section className="py-10 sm:py-14">
|
||||
<Container>
|
||||
<div className="mb-6">
|
||||
<p className="text-[13px] text-muted">
|
||||
<Link href="/" className="hover:text-brand">
|
||||
خانه
|
||||
</Link>
|
||||
<span className="mx-2">/</span>
|
||||
محصولات
|
||||
</p>
|
||||
<h1 className="mt-2 text-[22px] font-extrabold text-ink">
|
||||
{categoryTitle || (q ? `جستجو: ${q}` : "همه محصولات")}
|
||||
</h1>
|
||||
{result.total ? <p className="mt-1 text-[13px] text-muted">{faDigits(result.total)} کالا</p> : null}
|
||||
</div>
|
||||
{total ? <p className="mb-6 text-[13px] text-muted">{faDigits(total)} کالا</p> : null}
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="rounded-2xl border border-line bg-white px-6 py-16 text-center">
|
||||
@@ -84,5 +92,6 @@ export default async function ProductsPage({
|
||||
) : null}
|
||||
</Container>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,18 +37,14 @@ export function BlogCarousel({
|
||||
return (
|
||||
<section id="blog" className="py-20 sm:py-24">
|
||||
<Container>
|
||||
<div className="mb-10 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-[3px] w-8 bg-brand" />
|
||||
<p className="section-kicker">Blog</p>
|
||||
</div>
|
||||
<h2 className="mt-3 text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2>
|
||||
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
|
||||
نکتههایی از تعمیرگاه و بورس قطعات — برای مصرفکننده و همکار.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mt-3 flex items-center justify-between gap-4">
|
||||
<h2 className="text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByCard(1)}
|
||||
@@ -70,20 +66,27 @@ export function BlogCarousel({
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
|
||||
نکتههایی از تعمیرگاه و بورس قطعات — برای مصرفکننده و همکار.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto">
|
||||
{usingApi
|
||||
? posts.map((post) => (
|
||||
<article key={post.id} className="w-[min(85vw,340px)] shrink-0 snap-start">
|
||||
<article
|
||||
key={post.id}
|
||||
className="w-[min(85vw,340px)] shrink-0 snap-start rounded-2xl border border-line bg-white p-3"
|
||||
>
|
||||
<Link href={blogHref(post)} className="group block">
|
||||
<div className="overflow-hidden rounded-2xl">
|
||||
<div className="overflow-hidden rounded-xl">
|
||||
<Placeholder
|
||||
src={blogImage(post)}
|
||||
alt={post.title}
|
||||
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">
|
||||
<p className="mt-3 text-[12px] text-muted">
|
||||
{formatFaDate(post.publishedAt || post.createdAt)}
|
||||
</p>
|
||||
<h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3>
|
||||
@@ -94,16 +97,19 @@ export function BlogCarousel({
|
||||
</article>
|
||||
))
|
||||
: fallback.map((post) => (
|
||||
<article key={post.slug} className="w-[min(85vw,340px)] shrink-0 snap-start">
|
||||
<article
|
||||
key={post.slug}
|
||||
className="w-[min(85vw,340px)] shrink-0 snap-start rounded-2xl border border-line bg-white p-3"
|
||||
>
|
||||
<Link href={`/blog/${post.slug}`} className="group block">
|
||||
<div className="overflow-hidden rounded-2xl">
|
||||
<div className="overflow-hidden rounded-xl">
|
||||
<Placeholder
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p>
|
||||
<p className="mt-3 text-[12px] text-muted">{post.date}</p>
|
||||
<h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3>
|
||||
<p className="mt-2 line-clamp-2 text-[13px] leading-7 text-muted">{post.excerpt}</p>
|
||||
</Link>
|
||||
|
||||
+109
-45
@@ -1,72 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { SlotLink } from "@/components/SlotLink";
|
||||
import { parseAspectRatio } from "@/lib/static-images";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
function circularOffset(index: number, active: number, length: number) {
|
||||
let diff = index - active;
|
||||
if (diff > length / 2) diff -= length;
|
||||
if (diff < -length / 2) diff += length;
|
||||
return diff;
|
||||
}
|
||||
const SLIDE_W = "min(1280px, calc(100vw - 5rem))";
|
||||
const GAP = "1rem";
|
||||
const INTERVAL_MS = 6000;
|
||||
const TRANSITION_MS = 700;
|
||||
|
||||
const slideWidth = "min(1280px, calc(100vw - 5rem))";
|
||||
|
||||
export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectRatio?: string }) {
|
||||
export function Hero({ slides }: { slides: SlotMediaItem[]; aspectRatio?: string }) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [shift, setShift] = useState(0);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const ratio = parseAspectRatio(aspectRatio);
|
||||
const lock = useRef(false);
|
||||
const shiftRef = useRef(0);
|
||||
const countRef = useRef(slides.length);
|
||||
countRef.current = slides.length;
|
||||
|
||||
const finishTransition = () => {
|
||||
if (!lock.current) return;
|
||||
lock.current = false;
|
||||
const delta = shiftRef.current;
|
||||
const total = countRef.current;
|
||||
shiftRef.current = 0;
|
||||
setActive((current) => (current + delta + total) % total);
|
||||
setShift(0);
|
||||
setAnimating(false);
|
||||
};
|
||||
|
||||
const go = (direction: -1 | 1) => {
|
||||
if (countRef.current < 2 || lock.current) return;
|
||||
lock.current = true;
|
||||
shiftRef.current = direction;
|
||||
setAnimating(true);
|
||||
setShift(direction);
|
||||
window.setTimeout(() => {
|
||||
if (lock.current && shiftRef.current === direction) finishTransition();
|
||||
}, TRANSITION_MS + 80);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
setShift(0);
|
||||
shiftRef.current = 0;
|
||||
setAnimating(false);
|
||||
lock.current = false;
|
||||
}, [slides.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slides.length < 2 || paused) return;
|
||||
const timer = setInterval(() => {
|
||||
setActive((current) => (current + 1) % slides.length);
|
||||
}, 6000);
|
||||
return () => clearInterval(timer);
|
||||
}, [paused, slides.length]);
|
||||
|
||||
const go = (direction: number) => {
|
||||
if (slides.length < 2) return;
|
||||
setActive((current) => (current + direction + slides.length) % slides.length);
|
||||
};
|
||||
if (slides.length < 2 || paused || animating) return;
|
||||
const timer = window.setInterval(() => go(1), INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [paused, animating, slides.length, active]);
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
const count = slides.length;
|
||||
const offsets =
|
||||
count === 1
|
||||
? ([0] as number[])
|
||||
: shift > 0
|
||||
? ([-1, 0, 1, 2] as number[])
|
||||
: shift < 0
|
||||
? ([-2, -1, 0, 1] as number[])
|
||||
: ([-1, 0, 1] as number[]);
|
||||
|
||||
return (
|
||||
<section className="relative overflow-x-clip bg-white pt-5 pb-8">
|
||||
<div
|
||||
className={ratio ? "relative min-h-[340px]" : "relative h-[min(62vh,640px)] min-h-[340px]"}
|
||||
style={ratio ? { aspectRatio: ratio, maxHeight: 640 } : undefined}
|
||||
className="relative h-[min(62vh,640px)] min-h-[340px]"
|
||||
style={{ ["--slide-w" as string]: SLIDE_W, ["--slide-gap" as string]: GAP }}
|
||||
dir="ltr"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
{slides.map((item, i) => {
|
||||
const offset = circularOffset(i, active, slides.length);
|
||||
const isCenter = offset === 0;
|
||||
const isPeek = Math.abs(offset) === 1;
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: `translateX(calc(${-shift} * (var(--slide-w) + var(--slide-gap))))`,
|
||||
transition: animating
|
||||
? `transform ${TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
||||
: "none",
|
||||
}}
|
||||
onTransitionEnd={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.propertyName !== "transform") return;
|
||||
finishTransition();
|
||||
}}
|
||||
>
|
||||
{offsets.map((offset) => {
|
||||
const index = (active + offset + count * 10) % count;
|
||||
const item = slides[index];
|
||||
const isActiveSlot = offset === shift;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="absolute top-0 h-full w-[min(1280px,calc(100vw-5rem))]"
|
||||
key={`slot-${offset}`}
|
||||
className="absolute top-0 h-full"
|
||||
style={{
|
||||
left: `calc(50% - (${slideWidth}) / 2 + ${offset} * ((${slideWidth}) + 1rem))`,
|
||||
zIndex: isCenter ? 3 : isPeek ? 2 : 1,
|
||||
opacity: isCenter || isPeek ? 1 : 0,
|
||||
pointerEvents: isCenter || isPeek ? "auto" : "none",
|
||||
transition: "left 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease",
|
||||
width: "var(--slide-w)",
|
||||
left: `calc(50% - var(--slide-w) / 2 + ${offset} * (var(--slide-w) + var(--slide-gap)))`,
|
||||
zIndex: isActiveSlot ? 3 : 2,
|
||||
}}
|
||||
>
|
||||
{isCenter ? (
|
||||
{isActiveSlot ? (
|
||||
<SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl">
|
||||
<Placeholder
|
||||
src={item.image}
|
||||
@@ -89,7 +130,7 @@ export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectR
|
||||
<div dir="rtl" className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3 sm:gap-5">
|
||||
<h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]">
|
||||
{item.title}
|
||||
{item.title || "فروشگاه برادران برقعی"}
|
||||
</h1>
|
||||
<span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" />
|
||||
<span className="hidden h-6 w-px shrink-0 bg-white/20 sm:block" />
|
||||
@@ -109,22 +150,34 @@ export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectR
|
||||
<button
|
||||
type="button"
|
||||
className="relative block h-full w-full overflow-hidden rounded-2xl"
|
||||
onClick={() => setActive(i)}
|
||||
onClick={() => go(offset < shift ? -1 : 1)}
|
||||
aria-label={item.title}
|
||||
disabled={animating}
|
||||
>
|
||||
<Placeholder src={item.image} fallbackSrc={item.fallbackSrc} alt="" className="h-full w-full object-cover" />
|
||||
<Placeholder
|
||||
src={item.image}
|
||||
fallbackSrc={item.fallbackSrc}
|
||||
alt={item.title ?? ""}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-white/35" />
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-[min(1280px,calc(100vw-5rem))] -translate-x-1/2">
|
||||
{count > 1 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-1/2 z-10 -translate-x-1/2"
|
||||
style={{ width: "var(--slide-w)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(1)}
|
||||
className="pointer-events-auto absolute top-1/2 right-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm"
|
||||
disabled={animating}
|
||||
className="pointer-events-auto absolute top-1/2 right-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm disabled:opacity-60"
|
||||
aria-label="اسلاید راست"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
@@ -132,25 +185,36 @@ export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectR
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(-1)}
|
||||
className="pointer-events-auto absolute top-1/2 left-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm"
|
||||
disabled={animating}
|
||||
className="pointer-events-auto absolute top-1/2 left-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm disabled:opacity-60"
|
||||
aria-label="اسلاید چپ"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{count > 1 ? (
|
||||
<div className="relative z-10 mt-5 flex justify-center gap-2">
|
||||
{slides.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className={`h-1.5 rounded-full transition-all ${i === active ? "w-8 bg-brand" : "w-3 bg-ink/20"}`}
|
||||
disabled={animating}
|
||||
onClick={() => {
|
||||
if (i === active || lock.current) return;
|
||||
const forward = (i - active + count) % count;
|
||||
const backward = (active - i + count) % count;
|
||||
if (forward <= backward) go(1);
|
||||
else go(-1);
|
||||
}}
|
||||
className={`h-1.5 rounded-full transition-all ${i === active && shift === 0 ? "w-8 bg-brand" : "w-3 bg-ink/20"}`}
|
||||
aria-label={`اسلاید ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function Services({ items }: { items: SlotMediaItem[] }) {
|
||||
<Placeholder
|
||||
src={service.image}
|
||||
fallbackSrc={service.fallbackSrc}
|
||||
alt=""
|
||||
alt={service.title ?? ""}
|
||||
className="mt-1 size-16 shrink-0 object-contain sm:size-[4.5rem]"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Container } from "@/components/Container";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { SlotLink } from "@/components/SlotLink";
|
||||
import { glossyBar } from "@/lib/glossy";
|
||||
import { parseAspectRatio } from "@/lib/static-images";
|
||||
import type { SlotMediaItem } from "@/lib/static-images";
|
||||
|
||||
const fallbackCopy = {
|
||||
@@ -11,37 +10,32 @@ const fallbackCopy = {
|
||||
href: "/contact",
|
||||
};
|
||||
|
||||
export function WideBanner({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) {
|
||||
export function WideBanner({ image }: { image?: SlotMediaItem; aspectRatio?: string }) {
|
||||
const src = image?.image ?? "/images/banners/wide.jpg";
|
||||
const href = image?.href || fallbackCopy.href;
|
||||
const ratio = parseAspectRatio(aspectRatio);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`relative overflow-hidden ${ratio ? "min-h-[280px]" : "h-[280px] sm:h-[360px]"}`}
|
||||
style={ratio ? { aspectRatio: ratio } : undefined}
|
||||
>
|
||||
<section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]">
|
||||
<SlotLink href={href} className="absolute inset-0">
|
||||
<Placeholder
|
||||
src={src}
|
||||
fallbackSrc={image?.fallbackSrc ?? "/images/banners/wide.jpg"}
|
||||
alt=""
|
||||
alt={image?.title || fallbackCopy.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-ink/20" />
|
||||
</SlotLink>
|
||||
<Container className="relative flex h-full items-center">
|
||||
<div className="max-w-xl rounded-2xl px-8 py-7" style={glossyBar}>
|
||||
<div className="max-w-xl rounded-2xl px-6 py-4 sm:px-8 sm:py-5" style={glossyBar}>
|
||||
<p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">
|
||||
{image?.kicker || fallbackCopy.kicker}
|
||||
</p>
|
||||
<p className="mt-3 text-[22px] font-bold leading-9 text-white sm:text-[26px]">
|
||||
<p className="mt-1.5 line-clamp-2 text-[18px] font-bold leading-7 text-white sm:text-[22px]">
|
||||
{image?.title || fallbackCopy.title}
|
||||
</p>
|
||||
{image?.text ? <p className="mt-2 text-[14px] leading-7 text-white/75">{image.text}</p> : null}
|
||||
<SlotLink
|
||||
href={href}
|
||||
className="mt-5 inline-flex rounded-xl bg-brand px-6 py-3 text-[14px] font-medium text-white hover:bg-brand-dark"
|
||||
className="mt-3 inline-flex rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white hover:bg-brand-dark"
|
||||
>
|
||||
تماس با ما
|
||||
</SlotLink>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GlassBox } from "@/components/GlassBox";
|
||||
import { Placeholder } from "@/components/Placeholder";
|
||||
import { Container } from "@/components/Container";
|
||||
import { glossyBar } from "@/lib/glossy";
|
||||
|
||||
export function PageHero({
|
||||
kicker,
|
||||
@@ -12,17 +12,15 @@ export function PageHero({
|
||||
image: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative h-[42vh] min-h-[280px] overflow-hidden bg-ink">
|
||||
<Placeholder src={image} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-black/10" />
|
||||
<div className="absolute inset-x-0 bottom-0">
|
||||
<Container className="px-4 sm:px-6">
|
||||
<GlassBox frostSrc={image} className="relative mb-6 inline-block max-w-lg px-8 py-7">
|
||||
<p className="section-kicker">{kicker}</p>
|
||||
<h1 className="mt-2 text-3xl font-extrabold sm:text-5xl">{title}</h1>
|
||||
</GlassBox>
|
||||
</Container>
|
||||
<section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]">
|
||||
<Placeholder src={image} alt={title} className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-ink/75" />
|
||||
<Container className="relative flex h-full items-center px-4 sm:px-6">
|
||||
<div className="max-w-xl rounded-2xl px-6 py-4 sm:px-8 sm:py-5" style={glossyBar}>
|
||||
<p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">{kicker}</p>
|
||||
<h1 className="mt-1.5 line-clamp-2 text-[22px] font-extrabold leading-snug text-white sm:text-[28px]">{title}</h1>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="relative block w-full overflow-hidden rounded-2xl bg-paper"
|
||||
className="relative block aspect-square w-full overflow-hidden rounded-2xl bg-paper"
|
||||
onClick={() => setLightbox(true)}
|
||||
onMouseEnter={() => setZoomed(true)}
|
||||
onMouseLeave={() => {
|
||||
@@ -35,10 +35,10 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
src={current}
|
||||
fallbackSrc="/placeholders/product.svg"
|
||||
alt={alt}
|
||||
className="aspect-square w-full object-contain transition-transform duration-150"
|
||||
className="absolute inset-0 size-full object-cover transition-transform duration-150"
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 bg-contain bg-center bg-no-repeat"
|
||||
className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${current})`,
|
||||
transform: zoomed ? "scale(1.85)" : "scale(1)",
|
||||
@@ -56,7 +56,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string
|
||||
onClick={() => setActive(index)}
|
||||
className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-brand" : "border-line"}`}
|
||||
>
|
||||
<Placeholder src={image} className="size-full object-contain" />
|
||||
<Placeholder src={image} alt="" className="size-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function ProductCard({ product }: { product: Product }) {
|
||||
|
||||
return (
|
||||
<article className="group flex h-full flex-col rounded-2xl border border-line bg-white p-3">
|
||||
<Link href={href} className="relative overflow-hidden rounded-xl bg-paper">
|
||||
<Link href={href} className="relative block aspect-square overflow-hidden rounded-xl bg-paper">
|
||||
{product.discount ? (
|
||||
<span className="absolute top-2 right-2 z-10 rounded-md bg-brand px-1.5 py-0.5 text-[11px] font-bold text-white">
|
||||
{faDigits(product.discount)}٪
|
||||
@@ -18,7 +18,8 @@ export function ProductCard({ product }: { product: Product }) {
|
||||
<Placeholder
|
||||
src={product.image}
|
||||
fallbackSrc="/placeholders/product.svg"
|
||||
className="aspect-square w-full object-contain transition group-hover:scale-[1.03]"
|
||||
alt={product.title}
|
||||
className="absolute inset-0 size-full object-cover transition group-hover:scale-[1.03]"
|
||||
/>
|
||||
</Link>
|
||||
{product.brand ? (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
|
||||
label: "Full-row contact banner",
|
||||
kind: "single",
|
||||
pageKey: "home",
|
||||
aspectRatio: "32:9",
|
||||
aspectRatio: "64:9",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 1920,
|
||||
},
|
||||
|
||||
+95
-10
@@ -30,6 +30,8 @@ export type StoreSpecialProduct = {
|
||||
productTitle?: string | null;
|
||||
productNameFa?: string | null;
|
||||
productImage?: string | null;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number | null;
|
||||
variants?: StoreVariant[];
|
||||
};
|
||||
|
||||
@@ -48,6 +50,12 @@ export type CatalogProduct = {
|
||||
categoryNameFa?: string | null;
|
||||
categoryName?: string | null;
|
||||
commentCount?: number;
|
||||
store?: {
|
||||
variantCount?: number | null;
|
||||
minPrice?: number | null;
|
||||
maxPrice?: number | null;
|
||||
inStock?: boolean | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type Paged<T> = {
|
||||
@@ -97,9 +105,11 @@ export function variantToProduct(variant: StoreVariant): Product {
|
||||
};
|
||||
}
|
||||
|
||||
export function specialItemToProduct(item: StoreSpecialProduct): Product | null {
|
||||
export function specialItemToProduct(item: StoreSpecialProduct, slug?: string | null): Product | null {
|
||||
if (item.isActive === false) return null;
|
||||
|
||||
const variant = item.variants?.[0];
|
||||
if (!variant) return null;
|
||||
if (variant) {
|
||||
return variantToProduct({
|
||||
...variant,
|
||||
productId: item.productId ?? variant.productId,
|
||||
@@ -107,6 +117,25 @@ export function specialItemToProduct(item: StoreSpecialProduct): Product | null
|
||||
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<Paged<CatalogProduct>>(tenantPath(`/products?${query.toString()}`), {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
});
|
||||
}
|
||||
|
||||
export function catalogProductToCard(product: CatalogProduct): Product {
|
||||
const price = asNumber(product.store?.minPrice);
|
||||
return {
|
||||
id: String(product.id),
|
||||
href: product.slug ? `/products/${encodeURIComponent(product.slug)}` : `/products?q=${encodeURIComponent(product.nameFa || product.title || product.id)}`,
|
||||
title: product.nameFa?.trim() || product.title?.trim() || "محصول",
|
||||
brand: brandName(product) || product.categoryNameFa || "",
|
||||
price: price ?? undefined,
|
||||
image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER,
|
||||
inStock: product.store?.inStock !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) {
|
||||
if (!title?.trim()) return null;
|
||||
if (!title?.trim() && !productId) return null;
|
||||
const query = title?.trim() || String(productId);
|
||||
const data = await meshkeeGet<Paged<CatalogProduct>>(
|
||||
tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=8`),
|
||||
{ items: [], total: 0, page: 1, pageSize: 8 },
|
||||
tenantPath(`/products?name=${encodeURIComponent(query)}&pageSize=12`),
|
||||
{ items: [], total: 0, page: 1, pageSize: 12 },
|
||||
);
|
||||
const match =
|
||||
data.items.find((item) => String(item.id) === String(productId)) ??
|
||||
data.items[0] ??
|
||||
null;
|
||||
if (!match?.slug) return match;
|
||||
const detail = await meshkeeGet<{ product?: CatalogProduct }>(
|
||||
tenantPath(`/products/${encodeURIComponent(match.slug)}`),
|
||||
return fetchCatalogProductBySlug(match.slug);
|
||||
}
|
||||
|
||||
export async function fetchCatalogProductBySlug(slug: string) {
|
||||
const data = await meshkeeGet<{ product?: CatalogProduct }>(
|
||||
tenantPath(`/products/${encodeURIComponent(slug)}`),
|
||||
{},
|
||||
);
|
||||
return detail.product ?? match;
|
||||
return data.product ?? null;
|
||||
}
|
||||
|
||||
export function brandName(product?: CatalogProduct | null) {
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user