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 |
|
| `/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
@@ -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: [] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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 { 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
@@ -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>
|
||||||
|
|||||||
+27
-18
@@ -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,32 +28,34 @@ 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 (
|
||||||
|
<>
|
||||||
|
<PageHero
|
||||||
|
kicker="Products"
|
||||||
|
title={heroTitle}
|
||||||
|
image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg"
|
||||||
|
/>
|
||||||
<section className="py-10 sm:py-14">
|
<section className="py-10 sm:py-14">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="mb-6">
|
{total ? <p className="mb-6 text-[13px] text-muted">{faDigits(total)} کالا</p> : null}
|
||||||
<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>
|
|
||||||
|
|
||||||
{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">
|
||||||
@@ -84,5 +92,6 @@ export default async function ProductsPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,18 +37,14 @@ 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>
|
||||||
<h2 className="mt-3 text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2>
|
<div className="mt-3 flex items-center justify-between gap-4">
|
||||||
<p className="mt-2 max-w-xl text-[14px] leading-7 text-muted">
|
<h2 className="text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2>
|
||||||
نکتههایی از تعمیرگاه و بورس قطعات — برای مصرفکننده و همکار.
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => scrollByCard(1)}
|
onClick={() => scrollByCard(1)}
|
||||||
@@ -70,20 +66,27 @@ export function BlogCarousel({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
||||||
|
|||||||
+109
-45
@@ -1,72 +1,113 @@
|
|||||||
"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",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCenter ? (
|
{isActiveSlot ? (
|
||||||
<SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl">
|
<SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl">
|
||||||
<Placeholder
|
<Placeholder
|
||||||
src={item.image}
|
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 dir="rtl" className="flex items-center justify-between gap-4">
|
||||||
<div className="flex min-w-0 items-center gap-3 sm:gap-5">
|
<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]">
|
<h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]">
|
||||||
{item.title}
|
{item.title || "فروشگاه برادران برقعی"}
|
||||||
</h1>
|
</h1>
|
||||||
<span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" />
|
<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" />
|
<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
|
<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={() => setActive(i)}
|
onClick={() => go(offset < shift ? -1 : 1)}
|
||||||
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
|
||||||
|
src={item.image}
|
||||||
|
fallbackSrc={item.fallbackSrc}
|
||||||
|
alt={item.title ?? ""}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
<span className="absolute inset-0 bg-white/35" />
|
<span className="absolute inset-0 bg-white/35" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</article>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(1)}
|
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="اسلاید راست"
|
aria-label="اسلاید راست"
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-5" />
|
<ChevronRight className="size-5" />
|
||||||
@@ -132,25 +185,36 @@ export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectR
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(-1)}
|
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="اسلاید چپ"
|
aria-label="اسلاید چپ"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-5" />
|
<ChevronLeft className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{count > 1 ? (
|
||||||
<div className="relative z-10 mt-5 flex justify-center gap-2">
|
<div className="relative z-10 mt-5 flex justify-center gap-2">
|
||||||
{slides.map((item, i) => (
|
{slides.map((item, i) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActive(i)}
|
disabled={animating}
|
||||||
className={`h-1.5 rounded-full transition-all ${i === active ? "w-8 bg-brand" : "w-3 bg-ink/20"}`}
|
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}`}
|
aria-label={`اسلاید ${i + 1}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</GlassBox>
|
|
||||||
</Container>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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",
|
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,
|
||||||
},
|
},
|
||||||
|
|||||||
+95
-10
@@ -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,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];
|
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,
|
||||||
@@ -109,6 +119,25 @@ export function specialItemToProduct(item: StoreSpecialProduct): Product | null
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Product-source specials often ship with empty variants[].
|
||||||
|
const title = item.productNameFa?.trim() || item.productTitle?.trim();
|
||||||
|
if (!title && !item.productImage) return null;
|
||||||
|
|
||||||
|
const id = String(item.productId ?? item.id);
|
||||||
|
const href = slug
|
||||||
|
? `/products/${encodeURIComponent(slug)}`
|
||||||
|
: `/products?q=${encodeURIComponent(title || id)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
href,
|
||||||
|
title: title || "محصول",
|
||||||
|
brand: "",
|
||||||
|
image: item.productImage || PLACEHOLDER,
|
||||||
|
inStock: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchStoreItems(params: {
|
export async function fetchStoreItems(params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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